Merge remote-tracking branch 'origin/queue-overlay-deletions' into queue-overlay-additions

This commit is contained in:
Benjamin Lu
2025-12-13 15:49:43 -08:00
254 changed files with 5354 additions and 2131 deletions

View File

@@ -15,14 +15,7 @@
:style="style"
class="flex flex-col items-stretch"
>
<Panel
ref="panelRef"
:class="panelRootClass"
:pt="{
header: { class: 'hidden' },
content: { class: docked ? 'p-0' : 'p-1' }
}"
>
<div ref="panelRef" :class="cn(panelRootClass, docked ? 'p-0' : 'p-1')">
<div class="flex flex-col">
<div class="flex items-center select-none">
<span
@@ -35,7 +28,9 @@
"
/>
<ComfyRunButton />
<Suspense @resolve="comfyRunButtonResolved">
<ComfyRunButton />
</Suspense>
<IconButton
v-tooltip.bottom="cancelJobTooltipConfig"
type="transparent"
@@ -74,7 +69,7 @@
</IconTextButton>
</div>
</div>
</Panel>
</div>
<div v-if="isFloating" class="flex justify-end pt-1 pr-1">
<QueueInlineProgressSummary
@@ -103,9 +98,7 @@ import {
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
import type { ComponentPublicInstance } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import IconButton from '@/components/button/IconButton.vue'
import IconTextButton from '@/components/button/IconTextButton.vue'
@@ -143,7 +136,7 @@ const visible = computed(() => position.value !== 'Disabled')
const tabContainer = document.querySelector('.workflow-tabs-container')
const actionbarWrapperRef = ref<HTMLElement | null>(null)
const panelRef = ref<HTMLElement | ComponentPublicInstance | null>(null)
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const docked = computed({
get: () => props.docked ?? false,
@@ -244,7 +237,14 @@ const setInitialPosition = () => {
}
}
}
onMounted(setInitialPosition)
//The ComfyRunButton is a dynamic import. Which means it will not be loaded onMount in this component.
//So we must use suspense resolve to ensure that is has loaded and updated the DOM before calling setInitialPosition()
async function comfyRunButtonResolved() {
await nextTick()
setInitialPosition()
}
watch(visible, async (newVisible) => {
if (newVisible) {
await nextTick(setInitialPosition)

View File

@@ -58,7 +58,7 @@ const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
)
const { t } = useI18n()

View File

@@ -83,7 +83,7 @@ const props = withDefaults(defineProps<Props>(), {
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
)
const { t } = useI18n()

View File

@@ -1,13 +1,20 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import SearchBox from './SearchBox.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
interface GenericMeta<C> extends Omit<Meta<C>, 'component'> {
component: Omit<ComponentExposed<C>, 'focus'>
}
const meta: Meta<typeof SearchBox> = {
const meta: GenericMeta<typeof SearchBox> = {
title: 'Components/Input/SearchBox',
component: SearchBox,
tags: ['autodocs'],
argTypes: {
modelValue: {
control: 'text'
},
placeholder: {
control: 'text'
},
@@ -19,9 +26,12 @@ const meta: Meta<typeof SearchBox> = {
control: 'select',
options: ['md', 'lg'],
description: 'Size variant of the search box'
}
},
'onUpdate:modelValue': { action: 'update:modelValue' },
onSearch: { action: 'search' }
},
args: {
modelValue: '',
placeholder: 'Search...',
showBorder: false,
size: 'md'

View File

@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import SearchBox from './SearchBox.vue'
import SearchBox from '@/components/common/SearchBox.vue'
const i18n = createI18n({
legacy: false,
@@ -50,15 +50,15 @@ describe('SearchBox', () => {
await input.setValue('test')
// Model should not update immediately
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
expect(wrapper.emitted('search')).toBeFalsy()
// Advance timers by 299ms (just before debounce delay)
vi.advanceTimersByTime(299)
await vi.advanceTimersByTimeAsync(299)
await nextTick()
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
expect(wrapper.emitted('search')).toBeFalsy()
// Advance timers by 1ms more (reaching 300ms)
vi.advanceTimersByTime(1)
await vi.advanceTimersByTimeAsync(1)
await nextTick()
// Model should now be updated
@@ -82,19 +82,19 @@ describe('SearchBox', () => {
// Type third character (should reset timer again)
await input.setValue('tes')
vi.advanceTimersByTime(200)
await vi.advanceTimersByTimeAsync(200)
await nextTick()
// Should not have emitted yet (only 200ms passed since last keystroke)
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
expect(wrapper.emitted('search')).toBeFalsy()
// Advance final 100ms to reach 300ms
vi.advanceTimersByTime(100)
await vi.advanceTimersByTimeAsync(100)
await nextTick()
// Should now emit with final value
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['tes'])
expect(wrapper.emitted('search')).toBeTruthy()
expect(wrapper.emitted('search')?.[0]).toEqual(['tes', []])
})
it('should only emit final value after rapid typing', async () => {
@@ -105,19 +105,20 @@ describe('SearchBox', () => {
const searchTerms = ['s', 'se', 'sea', 'sear', 'searc', 'search']
for (const term of searchTerms) {
await input.setValue(term)
vi.advanceTimersByTime(50) // Less than debounce delay
await vi.advanceTimersByTimeAsync(50) // Less than debounce delay
}
await nextTick()
// Should not have emitted yet
expect(wrapper.emitted('update:modelValue')).toBeFalsy()
expect(wrapper.emitted('search')).toBeFalsy()
// Complete the debounce delay
vi.advanceTimersByTime(300)
await vi.advanceTimersByTimeAsync(350)
await nextTick()
// Should emit only once with final value
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['search'])
expect(wrapper.emitted('search')).toHaveLength(1)
expect(wrapper.emitted('search')?.[0]).toEqual(['search', []])
})
describe('bidirectional model sync', () => {

View File

@@ -1,84 +1,93 @@
<template>
<div>
<IconField>
<Button
v-if="filterIcon"
class="p-inputicon filter-button"
:icon="filterIcon"
text
severity="contrast"
@click="$emit('showFilter', $event)"
/>
<InputText
ref="inputRef"
class="search-box-input w-full"
:model-value="modelValue"
:placeholder="placeholder"
:autofocus="autofocus"
@input="handleInput"
/>
<InputIcon v-if="!modelValue" :class="icon" />
<Button
v-if="modelValue"
class="p-inputicon clear-button"
icon="pi pi-times"
text
severity="contrast"
@click="clearSearch"
/>
</IconField>
<div
v-if="filters?.length"
class="search-filters flex flex-wrap gap-2 pt-2"
>
<SearchFilterChip
v-for="filter in filters"
:key="filter.id"
:text="filter.text"
:badge="filter.badge"
:badge-class="filter.badgeClass"
@remove="$emit('removeFilter', filter)"
/>
</div>
<div
:class="
cn(
'relative flex w-full items-center gap-2 bg-comfy-input cursor-text text-comfy-input-foreground',
customClass,
wrapperStyle
)
"
>
<InputText
ref="inputRef"
v-model="modelValue"
:placeholder
:autofocus
unstyled
class="absolute inset-0 size-full pl-11 border-none outline-none bg-transparent text-sm"
:aria-label="placeholder"
/>
<IconButton
v-if="filterIcon"
class="p-inputicon filter-button absolute right-0 inset-y-0 h-full m-0 p-0"
:icon="filterIcon"
severity="contrast"
@click="$emit('showFilter', $event)"
/>
<InputIcon v-if="!modelValue" :class="icon" />
<Button
v-if="modelValue"
class="p-inputicon clear-button"
icon="pi pi-times"
text
severity="contrast"
@click="modelValue = ''"
/>
</div>
<div v-if="filters?.length" class="search-filters flex flex-wrap gap-2 pt-2">
<SearchFilterChip
v-for="filter in filters"
:key="filter.id"
:text="filter.text"
:badge="filter.badge"
:badge-class="filter.badgeClass"
@remove="$emit('removeFilter', filter)"
/>
</div>
</template>
<script setup lang="ts" generic="TFilter extends SearchFilter">
import { debounce } from 'es-toolkit/compat'
import { cn } from '@comfyorg/tailwind-utils'
import { watchDebounced } from '@vueuse/core'
import Button from 'primevue/button'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import IconButton from '../button/IconButton.vue'
import type { SearchFilter } from './SearchFilterChip.vue'
import SearchFilterChip from './SearchFilterChip.vue'
const {
modelValue,
placeholder = 'Search...',
icon = 'pi pi-search',
debounceTime = 300,
filterIcon,
filters = [],
autofocus = false
autofocus = false,
showBorder = false,
size = 'md',
class: customClass
} = defineProps<{
modelValue: string
placeholder?: string
icon?: string
debounceTime?: number
filterIcon?: string
filters?: TFilter[]
autofocus?: boolean
showBorder?: boolean
size?: 'md' | 'lg'
class?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
(e: 'search', value: string, filters: TFilter[]): void
(e: 'showFilter', event: Event): void
(e: 'removeFilter', filter: TFilter): void
}>()
const modelValue = defineModel<string>({ required: true })
const inputRef = ref()
defineExpose({
@@ -87,20 +96,27 @@ defineExpose({
}
})
const emitSearch = debounce((value: string) => {
emit('search', value, filters)
}, debounceTime)
watchDebounced(
modelValue,
(value: string) => {
emit('search', value, filters)
},
{ debounce: debounceTime }
)
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
emitSearch(target.value)
}
const wrapperStyle = computed(() => {
if (showBorder) {
return cn('rounded p-2 border border-solid border-border-default')
}
const clearSearch = () => {
emit('update:modelValue', '')
emitSearch('')
}
// Size-specific classes matching button sizes for consistency
const sizeClasses = {
md: 'h-8 px-2 py-1.5', // Matches button sm size
lg: 'h-10 px-4 py-2' // Matches button md size
}[size]
return cn('rounded-lg', sizeClasses)
})
</script>
<style scoped>

View File

@@ -64,7 +64,8 @@ const formattedCreditsOnly = computed(() => {
const cents = authStore.balance?.amount_micros ?? 0
const amount = formatCreditsFromCents({
cents,
locale: locale.value
locale: locale.value,
numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
})
return amount
})

View File

@@ -388,8 +388,8 @@ import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchBox from '@/components/input/SearchBox.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'

View File

@@ -128,7 +128,7 @@ onMounted(async () => {
reportContent.value = generateErrorReport({
systemStats: systemStatsStore.systemStats!,
serverLogs: logs,
workflow: app.graph.serialize(),
workflow: app.rootGraph.serialize(),
exceptionType: error.exceptionType,
exceptionMessage: error.exceptionMessage,
traceback: error.traceback,

View File

@@ -3,7 +3,7 @@
<div v-if="useNewDesign" class="flex w-112 flex-col gap-8 p-8">
<!-- Header -->
<div class="flex flex-col gap-4">
<h1 class="text-2xl font-semibold text-white m-0">
<h1 class="text-2xl font-semibold text-base-foreground m-0">
{{
isInsufficientCredits
? $t('credits.topUp.addMoreCreditsToRun')
@@ -62,7 +62,7 @@
severity="primary"
:label="$t('credits.topUp.buy')"
:class="['w-full', { 'opacity-30': !selectedCredits || loading }]"
:pt="{ label: { class: 'text-white' } }"
:pt="{ label: { class: 'text-primary-foreground' } }"
@click="handleBuy"
/>
</div>
@@ -122,11 +122,7 @@ import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
creditsToUsd,
formatCredits,
formatUsd
} from '@/base/credits/comfyCredits'
import { creditsToUsd } from '@/base/credits/comfyCredits'
import UserCredit from '@/components/common/UserCredit.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
@@ -156,7 +152,7 @@ const { formattedRenewalDate } = useSubscription()
// Use feature flag to determine design - defaults to true (new design)
const useNewDesign = computed(() => flags.subscriptionTiersEnabled)
const { t, locale } = useI18n()
const { t } = useI18n()
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const toast = useToast()
@@ -191,19 +187,6 @@ const handleBuy = async () => {
const usdAmount = creditsToUsd(selectedCredits.value)
telemetry?.trackApiCreditTopupButtonPurchaseClicked(usdAmount)
await authActions.purchaseCredits(usdAmount)
toast.add({
severity: 'success',
summary: t('credits.topUp.purchaseSuccess'),
detail: t('credits.topUp.purchaseSuccessDetail', {
credits: formatCredits({
value: selectedCredits.value,
locale: locale.value
}),
amount: `$${formatUsd({ value: usdAmount, locale: locale.value })}`
}),
life: 3000
})
} catch (error) {
console.error('Purchase failed:', error)

View File

@@ -8,10 +8,10 @@
]"
@click="$emit('select')"
>
<span class="text-base font-bold text-white">
<span class="text-base font-bold text-base-foreground">
{{ formattedCredits }}
</span>
<span class="text-sm font-normal text-white">
<span class="text-sm font-normal text-muted-foreground">
{{ description }}
</span>
</div>

View File

@@ -60,12 +60,13 @@
</div>
<!-- Submit Button -->
<ProgressSpinner v-if="loading" class="h-8 w-8" />
<ProgressSpinner v-if="loading" class="mx-auto h-8 w-8" />
<Button
v-else
type="submit"
:label="t('auth.login.loginButton')"
class="mt-4 h-10 font-medium"
:disabled="!$form.valid"
/>
</Form>
</template>
@@ -74,6 +75,7 @@
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import { useThrottleFn } from '@vueuse/core'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
@@ -100,11 +102,11 @@ const emit = defineEmits<{
const emailInputId = 'comfy-org-sign-in-email'
const onSubmit = (event: FormSubmitEvent) => {
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
}
}
}, 1_500)
const handleForgotPassword = async (
email: string,

View File

@@ -1,5 +1,6 @@
<template>
<Form
v-slot="$form"
class="flex flex-col gap-6"
:resolver="zodResolver(signUpSchema)"
@submit="onSubmit"
@@ -28,10 +29,13 @@
<PasswordFields />
<!-- Submit Button -->
<ProgressSpinner v-if="loading" class="mx-auto h-8 w-8" />
<Button
v-else
type="submit"
:label="t('auth.signup.signUpButton')"
class="mt-4 h-10 font-medium"
:disabled="!$form.valid"
/>
</Form>
</template>
@@ -40,24 +44,30 @@
import type { FormSubmitEvent } from '@primevue/forms'
import { Form, FormField } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
import { useThrottleFn } from '@vueuse/core'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import ProgressSpinner from 'primevue/progressspinner'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import PasswordFields from './PasswordFields.vue'
const { t } = useI18n()
const authStore = useFirebaseAuthStore()
const loading = computed(() => authStore.loading)
const emit = defineEmits<{
submit: [values: SignUpData]
}>()
const onSubmit = (event: FormSubmitEvent) => {
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignUpData)
}
}
}, 1_500)
</script>

View File

@@ -4,7 +4,6 @@
synced with the stateStorage (localStorage). -->
<LiteGraphCanvasSplitterOverlay v-if="comfyAppReady">
<template v-if="showUI" #workflow-tabs>
<TryVueNodeBanner />
<div
v-if="workflowTabsPosition === 'Topbar'"
class="workflow-tabs-container pointer-events-auto relative h-9.5 w-full"
@@ -160,8 +159,8 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
import TryVueNodeBanner from '../topbar/TryVueNodeBanner.vue'
import SelectionRectangle from './SelectionRectangle.vue'
const emit = defineEmits<{
@@ -271,20 +270,18 @@ watch(
() => {
if (!canvasStore.canvas) return
for (const n of comfyApp.graph.nodes) {
if (!n.widgets) continue
forEachNode(comfyApp.rootGraph, (n) => {
if (!n.widgets) return
for (const w of n.widgets) {
if (w[IS_CONTROL_WIDGET]) {
updateControlWidgetLabel(w)
if (w.linkedWidgets) {
for (const l of w.linkedWidgets) {
updateControlWidgetLabel(l)
}
}
if (!w[IS_CONTROL_WIDGET]) continue
updateControlWidgetLabel(w)
if (!w.linkedWidgets) continue
for (const l of w.linkedWidgets) {
updateControlWidgetLabel(l)
}
}
}
comfyApp.graph.setDirtyCanvas(true)
})
canvasStore.canvas.setDirty(true)
}
)
@@ -334,7 +331,7 @@ watch(
}
// Force canvas redraw to ensure progress updates are visible
canvas.graph.setDirtyCanvas(true, false)
canvas.setDirty(true, false)
},
{ deep: true }
)
@@ -346,7 +343,7 @@ watch(
(lastNodeErrors) => {
if (!comfyApp.graph) return
for (const node of comfyApp.graph.nodes) {
forEachNode(comfyApp.rootGraph, (node) => {
// Clear existing errors
for (const slot of node.inputs) {
delete slot.hasErrors
@@ -356,7 +353,7 @@ watch(
}
const nodeErrors = lastNodeErrors?.[node.id]
if (!nodeErrors) continue
if (!nodeErrors) return
const validErrors = nodeErrors.errors.filter(
(error) => error.extra_info?.input_name !== undefined
@@ -369,9 +366,9 @@ watch(
node.inputs[inputIndex].hasErrors = true
}
})
}
})
comfyApp.canvas.draw(true, true)
comfyApp.canvas.setDirty(true, true)
}
)

View File

@@ -58,7 +58,7 @@ const onEdit = (newValue: string) => {
target.subgraph.name = trimmedTitle
}
app.graph.setDirtyCanvas(true, true)
app.canvas.setDirty(true, true)
}
showInput.value = false
titleEditorStore.titleEditorTarget = null

View File

@@ -178,7 +178,7 @@ import MultiSelect from 'primevue/multiselect'
import { computed, useAttrs } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/input/SearchBox.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import { usePopoverSizing } from '@/composables/usePopoverSizing'
import { cn } from '@/utils/tailwindUtil'

View File

@@ -1,93 +0,0 @@
<template>
<div :class="wrapperStyle" @click="focusInput">
<i class="icon-[lucide--search] text-muted-foreground" />
<InputText
ref="input"
v-model="internalSearchQuery"
:aria-label="
placeholder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
"
:placeholder="
placeholder || t('templateWidgets.sort.searchPlaceholder', 'Search...')
"
type="text"
unstyled
class="absolute inset-0 size-full pl-11 border-none outline-none bg-transparent text-sm text-base-foreground"
/>
</div>
</template>
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core'
import InputText from 'primevue/inputtext'
import { computed, onMounted, ref, watch } from 'vue'
import { t } from '@/i18n'
import { cn } from '@/utils/tailwindUtil'
const SEARCH_DEBOUNCE_DELAY_MS = 300
const {
autofocus = false,
placeholder,
showBorder = false,
size = 'md'
} = defineProps<{
autofocus?: boolean
placeholder?: string
showBorder?: boolean
size?: 'md' | 'lg'
}>()
// defineModel without arguments uses 'modelValue' as the prop name
const searchQuery = defineModel<string>()
// Internal search query state for immediate UI updates
const internalSearchQuery = ref<string>(searchQuery.value ?? '')
// Create debounced function to update the parent model
const updateSearchQuery = useDebounceFn((value: string) => {
searchQuery.value = value
}, SEARCH_DEBOUNCE_DELAY_MS)
// Watch internal query changes and trigger debounced update
watch(internalSearchQuery, (newValue) => {
void updateSearchQuery(newValue)
})
// Sync external changes back to internal state
watch(searchQuery, (newValue) => {
if (newValue !== internalSearchQuery.value) {
internalSearchQuery.value = newValue || ''
}
})
const input = ref<{ $el: HTMLElement } | null>()
const focusInput = () => {
if (input.value && input.value.$el) {
input.value.$el.focus()
}
}
onMounted(() => autofocus && focusInput())
const wrapperStyle = computed(() => {
const baseClasses =
'relative flex w-full items-center gap-2 bg-secondary-background cursor-text'
if (showBorder) {
return cn(
baseClasses,
'rounded p-2 border border-solid border-border-default'
)
}
// Size-specific classes matching button sizes for consistency
const sizeClasses = {
md: 'h-8 px-2 py-1.5', // Matches button sm size
lg: 'h-10 px-4 py-2' // Matches button md size
}[size]
return cn(baseClasses, 'rounded-lg', sizeClasses)
})
</script>

View File

@@ -23,6 +23,17 @@
</div>
</div>
</template>
<template #tool-buttons>
<!-- Normal Tab View -->
<TabList v-if="!isInFolderView" v-model="activeTab">
<Tab class="font-inter" value="output">{{
$t('sideToolbar.labels.generated')
}}</Tab>
<Tab class="font-inter" value="input">{{
$t('sideToolbar.labels.imported')
}}</Tab>
</TabList>
</template>
<template #header>
<!-- Job Detail View Header -->
<div v-if="isInFolderView" class="px-2 2xl:px-4">
@@ -36,15 +47,7 @@
</template>
</IconTextButton>
</div>
<!-- Normal Tab View -->
<TabList v-else v-model="activeTab" class="font-inter px-2 2xl:px-4">
<Tab class="font-inter" value="output">{{
$t('sideToolbar.labels.generated')
}}</Tab>
<Tab class="font-inter" value="input">{{
$t('sideToolbar.labels.imported')
}}</Tab>
</TabList>
<!-- Filter Bar -->
<MediaAssetFilterBar
v-model:search-query="searchQuery"
@@ -55,12 +58,11 @@
/>
</template>
<template #body>
<!-- Loading state -->
<div v-if="loading">
<Divider type="dashed" class="m-2" />
<div v-if="loading && !displayAssets.length">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>
<!-- Empty state -->
<div v-else-if="!displayAssets.length">
<div v-else-if="!loading && !displayAssets.length">
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
@@ -73,7 +75,6 @@
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
<!-- Content -->
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<VirtualGrid
:items="mediaAssetsWithKey"
@@ -174,6 +175,7 @@
<script setup lang="ts">
import { useDebounceFn, useElementHover, useResizeObserver } from '@vueuse/core'
import { Divider } from 'primevue'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'

View File

@@ -17,17 +17,19 @@
/>
</template>
<template #header>
<SearchBox
ref="searchBoxRef"
v-model:model-value="searchQuery"
class="model-lib-search-box p-2 2xl:p-4"
:placeholder="$t('g.searchModels') + '...'"
@search="handleSearch"
/>
<div class="px-2 2xl:px-4">
<SearchBox
ref="searchBoxRef"
v-model:model-value="searchQuery"
:placeholder="$t('g.searchModels') + '...'"
@search="handleSearch"
/>
</div>
</template>
<template #body>
<ElectronDownloadItems v-if="isElectron()" />
<Divider type="dashed" class="m-2" />
<TreeExplorer
v-model:expanded-keys="expandedKeys"
class="model-lib-tree-explorer"
@@ -43,6 +45,7 @@
</template>
<script setup lang="ts">
import { Divider } from 'primevue'
import Button from 'primevue/button'
import { computed, nextTick, onMounted, ref, toRef, watch } from 'vue'

View File

@@ -75,11 +75,11 @@
</Popover>
</template>
<template #header>
<div>
<div class="px-2 2xl:px-4">
<SearchBox
ref="searchBoxRef"
v-model:model-value="searchQuery"
class="node-lib-search-box p-2 2xl:p-4"
class="node-lib-search-box"
:placeholder="$t('g.searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters

View File

@@ -5,7 +5,7 @@
>
<div class="comfy-vue-side-bar-header flex flex-col gap-2">
<Toolbar
class="min-h-15.5 bg-transparent rounded-none border-x-0 border-t-0 px-2 2xl:px-4"
class="min-h-16 bg-transparent rounded-none border-x-0 border-t-0 px-2 2xl:px-4"
>
<template #start>
<span class="truncate font-bold" :title="props.title">

View File

@@ -13,13 +13,15 @@
/>
</template>
<template #header>
<SearchBox
ref="searchBoxRef"
v-model:model-value="searchQuery"
class="workflows-search-box p-2 2xl:p-4"
:placeholder="$t('g.searchWorkflows') + '...'"
@search="handleSearch"
/>
<div class="px-2 2xl:px-4">
<SearchBox
ref="searchBoxRef"
v-model:model-value="searchQuery"
class="workflows-search-box"
:placeholder="$t('g.searchWorkflows') + '...'"
@search="handleSearch"
/>
</div>
</template>
<template #body>
<div v-if="!isSearching" class="comfyui-workflows-panel">

View File

@@ -33,7 +33,7 @@ const toast = useToast()
const workflowStore = useWorkflowStore()
const migrateToLitegraphReroute = async () => {
const workflowJSON = app.graph.serialize() as unknown as WorkflowJSON04
const workflowJSON = app.rootGraph.serialize() as unknown as WorkflowJSON04
const migratedWorkflowJSON = migrateLegacyRerouteNodes(workflowJSON)
await app.loadGraphData(
migratedWorkflowJSON,

View File

@@ -1,79 +0,0 @@
<template>
<Toast
group="vue-nodes-migration"
position="bottom-center"
class="w-auto"
@close="handleClose"
>
<template #message>
<div class="flex flex-auto items-center justify-between gap-4">
<span class="whitespace-nowrap">{{
t('vueNodesMigration.message')
}}</span>
<Button
class="whitespace-nowrap"
size="small"
:label="t('vueNodesMigration.button')"
text
@click="switchBack"
/>
</div>
</template>
</Toast>
<Toast
group="vue-nodes-check-main-menu"
position="bottom-center"
class="w-auto"
>
<template #message>
<div class="flex flex-auto items-center justify-between gap-4">
<span class="whitespace-nowrap">{{
t('vueNodesMigrationMainMenu.message')
}}</span>
</div>
</template>
</Toast>
</template>
<script setup lang="ts">
import { useToast } from 'primevue'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import { useI18n } from 'vue-i18n'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
const { t } = useI18n()
const toast = useToast()
const isDismissed = useVueNodesMigrationDismissed()
const switchBack = async () => {
await disableVueNodes()
toast.removeGroup('vue-nodes-migration')
isDismissed.value = true
showMainMenuToast()
}
const handleClose = () => {
isDismissed.value = true
showMainMenuToast()
}
const disableVueNodes = async () => {
await useSettingStore().set('Comfy.VueNodes.Enabled', false)
useTelemetry()?.trackUiButtonClicked({
button_id: `vue_nodes_migration_toast_switch_back_clicked`
})
}
const showMainMenuToast = () => {
useToastStore().add({
group: 'vue-nodes-check-main-menu',
severity: 'info',
life: 5000
})
}
</script>

View File

@@ -248,7 +248,7 @@ describe('CurrentUserPopover', () => {
// Verify window.open was called with the correct URL
expect(window.open).toHaveBeenCalledWith(
'https://docs.comfy.org/tutorials/api-nodes/overview#api-nodes',
'https://docs.comfy.org/tutorials/partner-nodes/pricing',
'_blank'
)

View File

@@ -21,12 +21,21 @@
<p v-if="userEmail" class="my-0 truncate text-sm text-muted">
{{ userEmail }}
</p>
<p v-if="subscriptionTierName" class="my-0 truncate text-sm text-muted">
{{ subscriptionTierName }}
</p>
</div>
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-amber-400 text-sm" />
<span class="text-base font-normal text-base-foreground flex-1">{{
<Skeleton
v-if="authStore.isFetchingBalance"
width="4rem"
height="1.25rem"
class="flex-1"
/>
<span v-else class="text-base font-normal text-base-foreground flex-1">{{
formattedBalance
}}</span>
<Button
@@ -39,14 +48,15 @@
/>
</div>
<SubscribeButton
v-else
class="mx-4"
:label="$t('subscription.subscribeToComfyCloud')"
size="small"
variant="gradient"
@subscribed="handleSubscribed"
/>
<div v-else class="flex justify-center px-4">
<SubscribeButton
:fluid="false"
:label="$t('subscription.subscribeToComfyCloud')"
size="small"
variant="gradient"
@subscribed="handleSubscribed"
/>
</div>
<!-- Credits info row -->
<div
@@ -121,6 +131,7 @@
<script setup lang="ts">
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -152,7 +163,8 @@ const { userDisplayName, userEmail, userPhotoUrl, handleSignOut } =
const authActions = useFirebaseAuthActions()
const authStore = useFirebaseAuthStore()
const dialogService = useDialogService()
const { isActiveSubscription, fetchStatus } = useSubscription()
const { isActiveSubscription, subscriptionTierName, fetchStatus } =
useSubscription()
const { flags } = useFeatureFlags()
const { locale } = useI18n()
@@ -193,7 +205,7 @@ const handleTopUp = () => {
const handleOpenPartnerNodesInfo = () => {
window.open(
buildDocsUrl('/tutorials/api-nodes/overview#api-nodes', {
buildDocsUrl('/tutorials/partner-nodes/pricing', {
includeLocale: true
}),
'_blank'

View File

@@ -1,74 +0,0 @@
<template>
<div
v-if="showVueNodesBanner"
class="pointer-events-auto relative w-full h-10 bg-gradient-to-r from-blue-600 to-blue-700 flex items-center justify-center px-4"
>
<div class="flex items-center text-sm text-white">
<i class="icon-[lucide--rocket]"></i>
<span class="pl-2">{{ $t('vueNodesBanner.title') }}</span>
<span class="pl-1.5 hidden md:inline">{{
$t('vueNodesBanner.desc')
}}</span>
<Button
class="cursor-pointer bg-transparent rounded h-7 px-3 border border-white text-white ml-4 text-xs"
@click="handleTryItOut"
>
{{ $t('vueNodesBanner.tryItOut') }}
</Button>
</div>
<Button
class="cursor-pointer bg-transparent border-0 outline-0 grid place-items-center absolute right-4 text-white"
unstyled
@click="handleDismiss"
>
<i class="w-5 h-5 icon-[lucide--x]"></i>
</Button>
</div>
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import Button from 'primevue/button'
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
const STORAGE_KEY = 'vueNodesBannerDismissed'
const settingStore = useSettingStore()
const bannerDismissed = useLocalStorage(STORAGE_KEY, false)
const vueNodesEnabled = computed(() => {
try {
return settingStore.get('Comfy.VueNodes.Enabled') ?? false
} catch {
return false
}
})
const showVueNodesBanner = computed(() => {
if (vueNodesEnabled.value) {
return false
}
if (bannerDismissed.value) {
return false
}
return true
})
const handleDismiss = (): void => {
bannerDismissed.value = true
}
const handleTryItOut = async (): Promise<void> => {
try {
await settingStore.set('Comfy.VueNodes.Enabled', true)
} catch (error) {
console.error('Failed to enable Nodes 2.0:', error)
} finally {
handleDismiss()
}
}
</script>

View File

@@ -140,8 +140,8 @@ import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchBox from '@/components/input/SearchBox.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'

View File

@@ -9,7 +9,7 @@ import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchBox from '@/components/input/SearchBox.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'