diff --git a/.cursorrules b/.cursorrules
deleted file mode 100644
index 6f43623a3d..0000000000
--- a/.cursorrules
+++ /dev/null
@@ -1,61 +0,0 @@
-# Vue 3 Composition API Project Rules
-
-## Vue 3 Composition API Best Practices
-- Use setup() function for component logic
-- Utilize ref and reactive for reactive state
-- Implement computed properties with computed()
-- Use watch and watchEffect for side effects
-- Implement lifecycle hooks with onMounted, onUpdated, etc.
-- Utilize provide/inject for dependency injection
-- Use vue 3.5 style of default prop declaration. Example:
-
-```typescript
-const { nodes, showTotal = true } = defineProps<{
- nodes: ApiNodeCost[]
- showTotal?: boolean
-}>()
-```
-
-- Organize vue component in
diff --git a/apps/desktop-ui/src/components/install/HardwareOption.stories.ts b/apps/desktop-ui/src/components/install/HardwareOption.stories.ts
index d830af49fa..fc0e567138 100644
--- a/apps/desktop-ui/src/components/install/HardwareOption.stories.ts
+++ b/apps/desktop-ui/src/components/install/HardwareOption.stories.ts
@@ -29,7 +29,6 @@ export const AppleMetalSelected: Story = {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
- value: 'mps',
selected: true
}
}
@@ -39,7 +38,6 @@ export const AppleMetalUnselected: Story = {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
- value: 'mps',
selected: false
}
}
@@ -48,7 +46,6 @@ export const CPUOption: Story = {
args: {
placeholderText: 'CPU',
subtitle: 'Subtitle',
- value: 'cpu',
selected: false
}
}
@@ -57,7 +54,6 @@ export const ManualInstall: Story = {
args: {
placeholderText: 'Manual Install',
subtitle: 'Subtitle',
- value: 'unsupported',
selected: false
}
}
@@ -67,7 +63,6 @@ export const NvidiaSelected: Story = {
imagePath: '/assets/images/nvidia-logo-square.jpg',
placeholderText: 'NVIDIA',
subtitle: 'NVIDIA',
- value: 'nvidia',
selected: true
}
}
diff --git a/apps/desktop-ui/src/components/install/HardwareOption.vue b/apps/desktop-ui/src/components/install/HardwareOption.vue
index ae254fd8f3..9acc9e79cf 100644
--- a/apps/desktop-ui/src/components/install/HardwareOption.vue
+++ b/apps/desktop-ui/src/components/install/HardwareOption.vue
@@ -36,17 +36,13 @@
diff --git a/src/components/MenuHamburger.vue b/src/components/MenuHamburger.vue
index 467561b54b..fa99e032c2 100644
--- a/src/components/MenuHamburger.vue
+++ b/src/components/MenuHamburger.vue
@@ -1,27 +1,27 @@
-
-
diff --git a/tests-ui/tests/components/TopMenuSection.test.ts b/src/components/TopMenuSection.test.ts
similarity index 100%
rename from tests-ui/tests/components/TopMenuSection.test.ts
rename to src/components/TopMenuSection.test.ts
diff --git a/src/components/TopMenuSection.vue b/src/components/TopMenuSection.vue
index 55c78dd5af..448a081f9e 100644
--- a/src/components/TopMenuSection.vue
+++ b/src/components/TopMenuSection.vue
@@ -10,40 +10,63 @@
-
-
-
+
-
-
-
-
- {{ queuedCount }}
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ queuedCount }}
+
+
+
+
+
+
+
+
-
-
diff --git a/src/components/actionbar/ComfyActionbar.vue b/src/components/actionbar/ComfyActionbar.vue
index d7d92c7ded..1d24e48385 100644
--- a/src/components/actionbar/ComfyActionbar.vue
+++ b/src/components/actionbar/ComfyActionbar.vue
@@ -10,7 +10,7 @@
-
@@ -43,17 +54,25 @@ import {
watchDebounced
} from '@vueuse/core'
import { clamp } from 'es-toolkit/compat'
+import { storeToRefs } from 'pinia'
import Panel from 'primevue/panel'
-import { computed, nextTick, onMounted, ref, watch } from 'vue'
+import { computed, nextTick, ref, watch } from 'vue'
+import { useI18n } from 'vue-i18n'
-import { t } from '@/i18n'
+import Button from '@/components/ui/button/Button.vue'
+import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
+import { useCommandStore } from '@/stores/commandStore'
+import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
import ComfyRunButton from './ComfyRunButton'
const settingsStore = useSettingStore()
+const commandStore = useCommandStore()
+const { t } = useI18n()
+const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
@@ -66,12 +85,7 @@ const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0
})
-const {
- x,
- y,
- style: style,
- isDragging
-} = useDraggable(panelRef, {
+const { x, y, style, isDragging } = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body,
@@ -126,7 +140,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)
@@ -255,6 +276,16 @@ watch(isDragging, (dragging) => {
isMouseOverDropZone.value = false
}
})
+
+const cancelJobTooltipConfig = computed(() =>
+ buildTooltipConfig(t('menu.interrupt'))
+)
+
+const cancelCurrentJob = async () => {
+ if (isExecutionIdle.value) return
+ await commandStore.execute('Comfy.Interrupt')
+}
+
const actionbarClass = computed(() =>
cn(
'w-[200px] border-dashed border-blue-500 opacity-80',
@@ -267,10 +298,10 @@ const actionbarClass = computed(() =>
)
const panelClass = computed(() =>
cn(
- 'actionbar pointer-events-auto z1000',
+ 'actionbar pointer-events-auto z-1300',
isDragging.value && 'select-none pointer-events-none',
isDocked.value
- ? 'p-0 static mr-2 border-none bg-transparent'
+ ? 'p-0 static border-none bg-transparent'
: 'fixed shadow-interface'
)
)
diff --git a/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue b/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue
index c9e8495549..4c0ea84e43 100644
--- a/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue
+++ b/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue
@@ -22,12 +22,13 @@
value: item.tooltip,
showDelay: 600
}"
- :label="String(item.label ?? '')"
- :icon="item.icon"
- :severity="item.key === queueMode ? 'primary' : 'secondary'"
- size="small"
- text
- />
+ :variant="item.key === queueMode ? 'primary' : 'secondary'"
+ size="sm"
+ class="w-full justify-start"
+ >
+
+ {{ String(item.label ?? '') }}
+
@@ -36,12 +37,12 @@
diff --git a/src/components/button/IconGroup.stories.ts b/src/components/button/IconGroup.stories.ts
index c2fa1b96df..2cf407c744 100644
--- a/src/components/button/IconGroup.stories.ts
+++ b/src/components/button/IconGroup.stories.ts
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
-import IconButton from './IconButton.vue'
+import Button from '@/components/ui/button/Button.vue'
import IconGroup from './IconGroup.vue'
const meta: Meta = {
@@ -16,18 +16,18 @@ type Story = StoryObj
export const Basic: Story = {
render: () => ({
- components: { IconGroup, IconButton },
+ components: { IconGroup, Button },
template: `
-
+
-
-
+
+
-
-
+
+
-
+
`
})
diff --git a/src/components/button/IconGroup.vue b/src/components/button/IconGroup.vue
index bec0ac7fb3..2575d37aea 100644
--- a/src/components/button/IconGroup.vue
+++ b/src/components/button/IconGroup.vue
@@ -1,17 +1,15 @@
-
+
diff --git a/src/components/button/IconTextButton.stories.ts b/src/components/button/IconTextButton.stories.ts
deleted file mode 100644
index 0139b9bd69..0000000000
--- a/src/components/button/IconTextButton.stories.ts
+++ /dev/null
@@ -1,213 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/vue3-vite'
-
-import IconTextButton from './IconTextButton.vue'
-
-const meta: Meta
= {
- title: 'Components/Button/IconTextButton',
- component: IconTextButton,
- tags: ['autodocs'],
- argTypes: {
- label: {
- control: 'text'
- },
- size: {
- control: { type: 'select' },
- options: ['sm', 'md']
- },
- type: {
- control: { type: 'select' },
- options: ['primary', 'secondary', 'transparent']
- },
- border: {
- control: 'boolean',
- description: 'Toggle border attribute'
- },
- disabled: {
- control: 'boolean',
- description: 'Toggle disable status'
- },
- iconPosition: {
- control: { type: 'select' },
- options: ['left', 'right']
- },
- onClick: { action: 'clicked' }
- }
-}
-
-export default meta
-type Story = StoryObj
-
-export const Primary: Story = {
- render: (args) => ({
- components: { IconTextButton },
- setup() {
- return { args }
- },
- template: `
-
-
-
-
-
- `
- }),
- args: {
- label: 'Deploy',
- type: 'primary',
- size: 'md'
- }
-}
-
-export const Secondary: Story = {
- render: (args) => ({
- components: { IconTextButton },
- setup() {
- return { args }
- },
- template: `
-
-
-
-
-
- `
- }),
- args: {
- label: 'Settings',
- type: 'secondary',
- size: 'md'
- }
-}
-
-export const Transparent: Story = {
- render: (args) => ({
- components: { IconTextButton },
- setup() {
- return { args }
- },
- template: `
-
-
-
-
-
- `
- }),
- args: {
- label: 'Cancel',
- type: 'transparent',
- size: 'md'
- }
-}
-
-export const WithIconRight: Story = {
- render: (args) => ({
- components: { IconTextButton },
- setup() {
- return { args }
- },
- template: `
-
-
-
-
-
- `
- }),
- args: {
- label: 'Next',
- type: 'primary',
- size: 'md',
- iconPosition: 'right'
- }
-}
-
-export const Small: Story = {
- render: (args) => ({
- components: { IconTextButton },
- setup() {
- return { args }
- },
- template: `
-
-
-
-
-
- `
- }),
- args: {
- label: 'Save',
- type: 'primary',
- size: 'sm'
- }
-}
-
-export const AllVariants: Story = {
- render: () => ({
- components: {
- IconTextButton
- },
- template: `
-
-
- {}">
-
-
-
-
- {}">
-
-
-
-
-
-
- {}">
-
-
-
-
- {}">
-
-
-
-
-
-
- {}">
-
-
-
-
- {}">
-
-
-
-
-
-
- {}">
-
-
-
-
- {}">
-
-
-
-
- {}">
-
-
-
-
-
-
- `
- }),
- parameters: {
- controls: { disable: true },
- actions: { disable: true }
- }
-}
diff --git a/src/components/button/IconTextButton.vue b/src/components/button/IconTextButton.vue
deleted file mode 100644
index 132622643e..0000000000
--- a/src/components/button/IconTextButton.vue
+++ /dev/null
@@ -1,58 +0,0 @@
-
-
-
- {{ label }}
-
-
-
-
-
diff --git a/src/components/button/MoreButton.stories.ts b/src/components/button/MoreButton.stories.ts
index ae1f865451..a0110722f9 100644
--- a/src/components/button/MoreButton.stories.ts
+++ b/src/components/button/MoreButton.stories.ts
@@ -1,6 +1,6 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
-import IconTextButton from './IconTextButton.vue'
+import Button from '@/components/ui/button/Button.vue'
import MoreButton from './MoreButton.vue'
const meta: Meta = {
@@ -17,30 +17,26 @@ type Story = StoryObj
export const Basic: Story = {
render: () => ({
- components: { MoreButton, IconTextButton },
+ components: { MoreButton, Button },
template: `
- { close() }"
>
-
-
-
-
+
+ Settings
+
- { close() }"
>
-
-
-
-
+
+ Profile
+
diff --git a/src/components/button/MoreButton.vue b/src/components/button/MoreButton.vue
index 0b0e6f2094..d192efb90c 100644
--- a/src/components/button/MoreButton.vue
+++ b/src/components/button/MoreButton.vue
@@ -1,9 +1,17 @@
-
-
-
-
+
+
+
{
+ isOpen = true
+ $emit('menuOpened')
+ }
+ "
+ @hide="
+ () => {
+ isOpen = false
+ $emit('menuClosed')
+ }
+ "
>
@@ -39,37 +57,29 @@
import Popover from 'primevue/popover'
import { ref } from 'vue'
-import type { BaseButtonProps } from '@/types/buttonTypes'
+import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
-import IconButton from './IconButton.vue'
-
-interface MoreButtonProps extends BaseButtonProps {
+interface MoreButtonProps {
isVertical?: boolean
}
-const popover = ref
>()
-
-const {
- size = 'md',
- type = 'secondary',
- isVertical = false
-} = defineProps()
+const { isVertical = false } = defineProps()
defineEmits<{
menuOpened: []
menuClosed: []
}>()
-const toggle = (event: Event) => {
- popover.value?.toggle(event)
-}
+const isOpen = ref(false)
+const popover = ref>()
-const hide = () => {
+function hide() {
popover.value?.hide()
}
defineExpose({
- hide
+ hide,
+ isOpen
})
diff --git a/src/components/button/TextButton.stories.ts b/src/components/button/TextButton.stories.ts
deleted file mode 100644
index 9c055a48bd..0000000000
--- a/src/components/button/TextButton.stories.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import type { Meta, StoryObj } from '@storybook/vue3-vite'
-
-import TextButton from './TextButton.vue'
-
-const meta: Meta = {
- title: 'Components/Button/TextButton',
- component: TextButton,
- tags: ['autodocs'],
- argTypes: {
- label: {
- control: 'text',
- defaultValue: 'Click me'
- },
- size: {
- control: { type: 'select' },
- options: ['sm', 'md'],
- defaultValue: 'md'
- },
- border: {
- control: 'boolean',
- description: 'Toggle border attribute'
- },
- disabled: {
- control: 'boolean',
- description: 'Toggle disable status'
- },
- type: {
- control: { type: 'select' },
- options: ['primary', 'secondary', 'transparent'],
- defaultValue: 'primary'
- },
- onClick: { action: 'clicked' }
- }
-}
-
-export default meta
-type Story = StoryObj
-
-export const Primary: Story = {
- args: {
- label: 'Primary Button',
- type: 'primary',
- size: 'md'
- }
-}
-
-export const Secondary: Story = {
- args: {
- label: 'Secondary Button',
- type: 'secondary',
- size: 'md'
- }
-}
-
-export const Transparent: Story = {
- args: {
- label: 'Transparent Button',
- type: 'transparent',
- size: 'md'
- }
-}
-
-export const Small: Story = {
- args: {
- label: 'Small Button',
- type: 'primary',
- size: 'sm'
- }
-}
-
-export const AllVariants: Story = {
- render: () => ({
- components: { TextButton },
- template: `
-
-
- {}" />
- {}" />
-
-
- {}" />
- {}" />
-
-
- {}" />
- {}" />
-
-
- `
- })
-}
diff --git a/src/components/button/TextButton.vue b/src/components/button/TextButton.vue
deleted file mode 100644
index 4dbe53b9cd..0000000000
--- a/src/components/button/TextButton.vue
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
- {{ label }}
-
-
-
-
diff --git a/src/components/card/Card.stories.ts b/src/components/card/Card.stories.ts
index cabc44d0d6..5ab01f4232 100644
--- a/src/components/card/Card.stories.ts
+++ b/src/components/card/Card.stories.ts
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
-import IconButton from '../button/IconButton.vue'
+import Button from '@/components/ui/button/Button.vue'
import SquareChip from '../chip/SquareChip.vue'
import CardBottom from './CardBottom.vue'
import CardContainer from './CardContainer.vue'
@@ -173,7 +173,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
CardBottom,
CardTitle,
CardDescription,
- IconButton,
+ Button,
SquareChip
},
setup() {
@@ -222,19 +222,19 @@ const createCardTemplate = (args: CardStoryArgs) => ({
- console.log('Info clicked')"
>
-
-
+
-
+
diff --git a/src/components/common/BackgroundImageUpload.vue b/src/components/common/BackgroundImageUpload.vue
index 46105e537a..5835aeace4 100644
--- a/src/components/common/BackgroundImageUpload.vue
+++ b/src/components/common/BackgroundImageUpload.vue
@@ -7,20 +7,24 @@
/>
+ >
+
+
+ >
+
+
diff --git a/src/components/common/TreeExplorer.vue b/src/components/common/TreeExplorer.vue
index c6a3dbe60e..828d8ff45a 100644
--- a/src/components/common/TreeExplorer.vue
+++ b/src/components/common/TreeExplorer.vue
@@ -2,7 +2,7 @@
({
+ initializeApp: vi.fn(),
+ getApp: vi.fn()
+}))
+
+vi.mock('firebase/auth', () => ({
+ getAuth: vi.fn(),
+ setPersistence: vi.fn(),
+ browserLocalPersistence: {},
+ onAuthStateChanged: vi.fn(),
+ signInWithEmailAndPassword: vi.fn(),
+ signOut: vi.fn()
+}))
+
+vi.mock('pinia')
+
+const mockBalance = vi.hoisted(() => ({
+ value: {
+ amount_micros: 100_000,
+ effective_balance_micros: 100_000,
+ currency: 'usd'
+ }
+}))
+
+const mockIsFetchingBalance = vi.hoisted(() => ({ value: false }))
+
+vi.mock('@/stores/firebaseAuthStore', () => ({
+ useFirebaseAuthStore: vi.fn(() => ({
+ balance: mockBalance.value,
+ isFetchingBalance: mockIsFetchingBalance.value
+ }))
+}))
+
+describe('UserCredit', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockBalance.value = {
+ amount_micros: 100_000,
+ effective_balance_micros: 100_000,
+ currency: 'usd'
+ }
+ mockIsFetchingBalance.value = false
+ })
+
+ const mountComponent = (props = {}) => {
+ const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: { en: enMessages }
+ })
+
+ return mount(UserCredit, {
+ props,
+ global: {
+ plugins: [i18n],
+ stubs: {
+ Skeleton: true,
+ Tag: true
+ }
+ }
+ })
+ }
+
+ describe('effective_balance_micros handling', () => {
+ it('uses effective_balance_micros when present (positive balance)', () => {
+ mockBalance.value = {
+ amount_micros: 200_000,
+ effective_balance_micros: 150_000,
+ currency: 'usd'
+ }
+
+ const wrapper = mountComponent()
+ expect(wrapper.text()).toContain('Credits')
+ })
+
+ it('uses effective_balance_micros when zero', () => {
+ mockBalance.value = {
+ amount_micros: 100_000,
+ effective_balance_micros: 0,
+ currency: 'usd'
+ }
+
+ const wrapper = mountComponent()
+ expect(wrapper.text()).toContain('0')
+ })
+
+ it('uses effective_balance_micros when negative', () => {
+ mockBalance.value = {
+ amount_micros: 0,
+ effective_balance_micros: -50_000,
+ currency: 'usd'
+ }
+
+ const wrapper = mountComponent()
+ expect(wrapper.text()).toContain('-')
+ })
+
+ it('falls back to amount_micros when effective_balance_micros is missing', () => {
+ mockBalance.value = {
+ amount_micros: 100_000,
+ currency: 'usd'
+ } as typeof mockBalance.value
+
+ const wrapper = mountComponent()
+ expect(wrapper.text()).toContain('Credits')
+ })
+
+ it('falls back to 0 when both effective_balance_micros and amount_micros are missing', () => {
+ mockBalance.value = {
+ currency: 'usd'
+ } as typeof mockBalance.value
+
+ const wrapper = mountComponent()
+ expect(wrapper.text()).toContain('0')
+ })
+ })
+
+ describe('loading state', () => {
+ it('shows skeleton when loading', () => {
+ mockIsFetchingBalance.value = true
+
+ const wrapper = mountComponent()
+ expect(wrapper.findComponent({ name: 'Skeleton' }).exists()).toBe(true)
+ })
+ })
+})
diff --git a/src/components/common/UserCredit.vue b/src/components/common/UserCredit.vue
index aac405b90c..987406ede5 100644
--- a/src/components/common/UserCredit.vue
+++ b/src/components/common/UserCredit.vue
@@ -8,12 +8,18 @@
-
{{ formattedBalance }}
+ >
+
+
+
+
+
+ {{ showCreditsOnly ? formattedCreditsOnly : formattedBalance }}
+
@@ -21,19 +27,42 @@
import Skeleton from 'primevue/skeleton'
import Tag from 'primevue/tag'
import { computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
-import { formatMetronomeCurrency } from '@/utils/formatUtil'
-const { textClass } = defineProps<{
+const { textClass, showCreditsOnly } = defineProps<{
textClass?: string
+ showCreditsOnly?: boolean
}>()
const authStore = useFirebaseAuthStore()
const balanceLoading = computed(() => authStore.isFetchingBalance)
+const { t, locale } = useI18n()
const formattedBalance = computed(() => {
- if (!authStore.balance) return '0.00'
- return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
+ const cents =
+ authStore.balance?.effective_balance_micros ??
+ authStore.balance?.amount_micros ??
+ 0
+ const amount = formatCreditsFromCents({
+ cents,
+ locale: locale.value
+ })
+ return `${amount} ${t('credits.credits')}`
+})
+
+const formattedCreditsOnly = computed(() => {
+ const cents =
+ authStore.balance?.effective_balance_micros ??
+ authStore.balance?.amount_micros ??
+ 0
+ const amount = formatCreditsFromCents({
+ cents,
+ locale: locale.value,
+ numberOptions: { minimumFractionDigits: 0, maximumFractionDigits: 0 }
+ })
+ return amount
})
diff --git a/src/components/common/VirtualGrid.vue b/src/components/common/VirtualGrid.vue
index 89de6e421b..134778778a 100644
--- a/src/components/common/VirtualGrid.vue
+++ b/src/components/common/VirtualGrid.vue
@@ -117,16 +117,7 @@ onBeforeUnmount(() => {
.scroll-container {
height: 100%;
overflow-y: auto;
-
- /* Firefox */
- scrollbar-width: none;
-
- &::-webkit-scrollbar {
- width: 1px;
- }
-
- &::-webkit-scrollbar-thumb {
- background-color: transparent;
- }
+ scrollbar-width: thin;
+ scrollbar-color: var(--dialog-surface) transparent;
}
diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
index a1174aa100..5f96fe4c26 100644
--- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
+++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
@@ -22,16 +22,17 @@
-
-
-
-
-
+
+ {{
+ $t('templateWorkflows.resetFilters', 'Clear Filters')
+ }}
+
@@ -301,16 +302,16 @@
v-if="template.tutorialUrl"
class="flex flex-col-reverse justify-center"
>
-
-
+
@@ -382,19 +383,18 @@ import ProgressSpinner from 'primevue/progressspinner'
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
-import IconButton from '@/components/button/IconButton.vue'
-import IconTextButton from '@/components/button/IconTextButton.vue'
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'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.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'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
diff --git a/src/components/dialog/GlobalDialog.vue b/src/components/dialog/GlobalDialog.vue
index 4566b06842..2074132b5b 100644
--- a/src/components/dialog/GlobalDialog.vue
+++ b/src/components/dialog/GlobalDialog.vue
@@ -14,6 +14,7 @@
diff --git a/src/components/dialog/confirm/ConfirmBody.vue b/src/components/dialog/confirm/ConfirmBody.vue
new file mode 100644
index 0000000000..9a1cd5980d
--- /dev/null
+++ b/src/components/dialog/confirm/ConfirmBody.vue
@@ -0,0 +1,19 @@
+
+
+
+ {{ promptTextReal }}
+
+
+
+
diff --git a/src/components/dialog/confirm/ConfirmFooter.vue b/src/components/dialog/confirm/ConfirmFooter.vue
new file mode 100644
index 0000000000..9cdd6e37b8
--- /dev/null
+++ b/src/components/dialog/confirm/ConfirmFooter.vue
@@ -0,0 +1,40 @@
+
+
+
+ {{ cancelTextX }}
+
+
+ {{ confirmTextX }}
+
+
+
+
diff --git a/src/components/dialog/confirm/ConfirmHeader.vue b/src/components/dialog/confirm/ConfirmHeader.vue
new file mode 100644
index 0000000000..3c83377331
--- /dev/null
+++ b/src/components/dialog/confirm/ConfirmHeader.vue
@@ -0,0 +1,12 @@
+
+
+ {{ title }}
+
+
+
diff --git a/src/components/dialog/confirm/confirmDialog.ts b/src/components/dialog/confirm/confirmDialog.ts
new file mode 100644
index 0000000000..c615e6475d
--- /dev/null
+++ b/src/components/dialog/confirm/confirmDialog.ts
@@ -0,0 +1,31 @@
+import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
+import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
+import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
+import { useDialogStore } from '@/stores/dialogStore'
+import type { ComponentAttrs } from 'vue-component-type-helpers'
+
+interface ConfirmDialogOptions {
+ headerProps?: ComponentAttrs
+ props?: ComponentAttrs
+ footerProps?: ComponentAttrs
+}
+
+export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
+ const dialogStore = useDialogStore()
+ const { headerProps, props, footerProps } = options
+ return dialogStore.showDialog({
+ headerComponent: ConfirmHeader,
+ component: ConfirmBody,
+ footerComponent: ConfirmFooter,
+ headerProps,
+ props,
+ footerProps,
+ dialogComponentProps: {
+ pt: {
+ header: 'py-0! px-0!',
+ content: 'p-0!',
+ footer: 'p-0!'
+ }
+ }
+ })
+}
diff --git a/src/components/dialog/content/ApiNodesSignInContent.vue b/src/components/dialog/content/ApiNodesSignInContent.vue
index a3139daaf9..41ad903c7b 100644
--- a/src/components/dialog/content/ApiNodesSignInContent.vue
+++ b/src/components/dialog/content/ApiNodesSignInContent.vue
@@ -11,24 +11,25 @@
-
+
+ {{ t('g.learnMore') }}
+
-
-
+
+ {{ t('g.cancel') }}
+
+
+ {{ t('g.login') }}
+
diff --git a/src/components/dialog/content/UpdatePasswordContent.vue b/src/components/dialog/content/UpdatePasswordContent.vue
index dc116e9c21..ef99a77880 100644
--- a/src/components/dialog/content/UpdatePasswordContent.vue
+++ b/src/components/dialog/content/UpdatePasswordContent.vue
@@ -7,12 +7,9 @@
-
+
+ {{ $t('userSettings.updatePassword') }}
+
@@ -20,10 +17,10 @@
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
-import Button from 'primevue/button'
import { ref } from 'vue'
import PasswordFields from '@/components/dialog/content/signin/PasswordFields.vue'
+import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { updatePasswordSchema } from '@/schemas/signInSchema'
diff --git a/src/components/dialog/content/credit/CreditTopUpOption.test.ts b/src/components/dialog/content/credit/CreditTopUpOption.test.ts
new file mode 100644
index 0000000000..7faf432e72
--- /dev/null
+++ b/src/components/dialog/content/credit/CreditTopUpOption.test.ts
@@ -0,0 +1,48 @@
+import { mount } from '@vue/test-utils'
+import { createI18n } from 'vue-i18n'
+import { describe, expect, it } from 'vitest'
+
+import CreditTopUpOption from '@/components/dialog/content/credit/CreditTopUpOption.vue'
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: { en: {} }
+})
+
+const mountOption = (
+ props?: Partial<{ credits: number; description: string; selected: boolean }>
+) =>
+ mount(CreditTopUpOption, {
+ props: {
+ credits: 1000,
+ description: '~100 videos*',
+ selected: false,
+ ...props
+ },
+ global: {
+ plugins: [i18n]
+ }
+ })
+
+describe('CreditTopUpOption', () => {
+ it('renders credit amount and description', () => {
+ const wrapper = mountOption({ credits: 5000, description: '~500 videos*' })
+ expect(wrapper.text()).toContain('5,000')
+ expect(wrapper.text()).toContain('~500 videos*')
+ })
+
+ it('applies unselected styling when not selected', () => {
+ const wrapper = mountOption({ selected: false })
+ expect(wrapper.find('div').classes()).toContain(
+ 'bg-component-node-disabled'
+ )
+ expect(wrapper.find('div').classes()).toContain('border-transparent')
+ })
+
+ it('emits select event when clicked', async () => {
+ const wrapper = mountOption()
+ await wrapper.find('div').trigger('click')
+ expect(wrapper.emitted('select')).toHaveLength(1)
+ })
+})
diff --git a/src/components/dialog/content/credit/CreditTopUpOption.vue b/src/components/dialog/content/credit/CreditTopUpOption.vue
index f134aba5e2..c67c273a27 100644
--- a/src/components/dialog/content/credit/CreditTopUpOption.vue
+++ b/src/components/dialog/content/credit/CreditTopUpOption.vue
@@ -1,81 +1,45 @@
-
-
-
(customAmount = Number(e.value))"
- @input="(e: InputNumberInputEvent) => (customAmount = Number(e.value))"
- />
- {{ amount }}
+
+
+ {{ formattedCredits }}
+
+
+ {{ description }}
+
-
-
diff --git a/src/components/dialog/content/error/FindIssueButton.vue b/src/components/dialog/content/error/FindIssueButton.vue
index 20f4f64896..767202251f 100644
--- a/src/components/dialog/content/error/FindIssueButton.vue
+++ b/src/components/dialog/content/error/FindIssueButton.vue
@@ -1,16 +1,14 @@
-
+
+
+ {{ $t('g.findIssues') }}
+
diff --git a/src/components/dialog/header/SettingDialogHeader.vue b/src/components/dialog/header/SettingDialogHeader.vue
index 66765846ff..959cfa14da 100644
--- a/src/components/dialog/header/SettingDialogHeader.vue
+++ b/src/components/dialog/header/SettingDialogHeader.vue
@@ -15,9 +15,7 @@
diff --git a/src/components/node/NodePreview.vue b/src/components/node/NodePreview.vue
index 42b486e467..107ee34713 100644
--- a/src/components/node/NodePreview.vue
+++ b/src/components/node/NodePreview.vue
@@ -2,7 +2,11 @@
https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c683087a3e168db/app/js/functions/sb_fn.js#L149
-->
-
+
()
const { shouldRenderVueNodes } = useVueFeatureFlags()
@@ -200,7 +205,6 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
}
._sb_node_preview {
- font-family: 'Open Sans', sans-serif;
color: var(--descrip-text);
border: 1px solid var(--descrip-text);
min-width: 300px;
diff --git a/src/components/queue/CompletionSummaryBanner.vue b/src/components/queue/CompletionSummaryBanner.vue
index 49fc5e4977..c0bf7aeb91 100644
--- a/src/components/queue/CompletionSummaryBanner.vue
+++ b/src/components/queue/CompletionSummaryBanner.vue
@@ -1,16 +1,15 @@
-
@@ -82,11 +81,11 @@
>
-
+
diff --git a/src/components/queue/job/QueueJobItem.stories.ts b/src/components/queue/job/QueueJobItem.stories.ts
index 21e02f7984..6f4246e79e 100644
--- a/src/components/queue/job/QueueJobItem.stories.ts
+++ b/src/components/queue/job/QueueJobItem.stories.ts
@@ -64,8 +64,7 @@ export const RunningWithCurrent: Story = {
state: 'running',
title: 'Generating image',
progressTotalPercent: 66,
- progressCurrentPercent: 10,
- runningNodeName: 'KSampler'
+ progressCurrentPercent: 10
}
}
diff --git a/src/components/queue/job/QueueJobItem.vue b/src/components/queue/job/QueueJobItem.vue
index bfe0eeda1c..7e71840bc3 100644
--- a/src/components/queue/job/QueueJobItem.vue
+++ b/src/components/queue/job/QueueJobItem.vue
@@ -67,7 +67,7 @@
/>
-
+
-
+
{{ props.title }}
@@ -113,7 +113,7 @@
This would eliminate the current duplication where the cancel button exists
both outside (for running) and inside (for pending) the Transition.
-->
-
+
-
-
-
+
-
-
+
- {{ t('menuLabels.View') }}
+
-
+
-
-
+
@@ -203,10 +198,9 @@
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
-import IconButton from '@/components/button/IconButton.vue'
-import TextButton from '@/components/button/TextButton.vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
+import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
@@ -225,7 +219,6 @@ const props = withDefaults(
showMenu?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
- runningNodeName?: string
activeDetailsId?: string | null
}>(),
{
@@ -355,6 +348,18 @@ const computedShowClear = computed(() => {
return props.state !== 'completed'
})
+const emitDetailsLeave = () => emit('details-leave', props.jobId)
+
+const onCancelClick = () => {
+ emitDetailsLeave()
+ emit('cancel')
+}
+
+const onDeleteClick = () => {
+ emitDetailsLeave()
+ emit('delete')
+}
+
const onContextMenu = (event: MouseEvent) => {
const shouldShowMenu = props.showMenu !== undefined ? props.showMenu : true
if (shouldShowMenu) emit('menu', event)
diff --git a/tests-ui/tests/components/queue/useJobErrorReporting.test.ts b/src/components/queue/job/useJobErrorReporting.test.ts
similarity index 92%
rename from tests-ui/tests/components/queue/useJobErrorReporting.test.ts
rename to src/components/queue/job/useJobErrorReporting.test.ts
index 0e0b83f6a7..abd444dbe7 100644
--- a/tests-ui/tests/components/queue/useJobErrorReporting.test.ts
+++ b/src/components/queue/job/useJobErrorReporting.test.ts
@@ -4,7 +4,10 @@ import type { ComputedRef } from 'vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import type { TaskItemImpl } from '@/stores/queueStore'
-import type { JobErrorDialogService } from '@/components/queue/job/useJobErrorReporting'
+import type {
+ JobErrorDialogService,
+ UseJobErrorReportingOptions
+} from '@/components/queue/job/useJobErrorReporting'
import * as jobErrorReporting from '@/components/queue/job/useJobErrorReporting'
const createExecutionErrorMessage = (
@@ -90,9 +93,9 @@ describe('extractExecutionError', () => {
describe('useJobErrorReporting', () => {
let taskState = ref
(null)
let taskForJob: ComputedRef
- let copyToClipboard: ReturnType
- let showExecutionErrorDialog: ReturnType
- let showErrorDialog: ReturnType
+ let copyToClipboard: UseJobErrorReportingOptions['copyToClipboard']
+ let showExecutionErrorDialog: JobErrorDialogService['showExecutionErrorDialog']
+ let showErrorDialog: JobErrorDialogService['showErrorDialog']
let dialog: JobErrorDialogService
let composable: ReturnType
@@ -146,7 +149,7 @@ describe('useJobErrorReporting', () => {
expect(copyToClipboard).toHaveBeenCalledTimes(1)
expect(copyToClipboard).toHaveBeenCalledWith('Clipboard failure')
- copyToClipboard.mockClear()
+ vi.mocked(copyToClipboard).mockClear()
taskState.value = createTaskWithMessages([])
composable.copyErrorMessage()
expect(copyToClipboard).not.toHaveBeenCalled()
@@ -174,7 +177,7 @@ describe('useJobErrorReporting', () => {
composable.reportJobError()
expect(showExecutionErrorDialog).not.toHaveBeenCalled()
expect(showErrorDialog).toHaveBeenCalledTimes(1)
- const [errorArg, optionsArg] = showErrorDialog.mock.calls[0]
+ const [errorArg, optionsArg] = vi.mocked(showErrorDialog).mock.calls[0]
expect(errorArg).toBeInstanceOf(Error)
expect(errorArg.message).toBe(message)
expect(optionsArg).toEqual({ reportType: 'queueJobError' })
diff --git a/src/components/queue/job/useJobErrorReporting.ts b/src/components/queue/job/useJobErrorReporting.ts
index b9ae57a127..afc0baf3e7 100644
--- a/src/components/queue/job/useJobErrorReporting.ts
+++ b/src/components/queue/job/useJobErrorReporting.ts
@@ -40,7 +40,7 @@ export const extractExecutionError = (
}
}
-type UseJobErrorReportingOptions = {
+export type UseJobErrorReportingOptions = {
taskForJob: ComputedRef
copyToClipboard: CopyHandler
dialog: JobErrorDialogService
diff --git a/src/components/rightSidePanel/RightSidePanel.vue b/src/components/rightSidePanel/RightSidePanel.vue
new file mode 100644
index 0000000000..4eb2ae31b8
--- /dev/null
+++ b/src/components/rightSidePanel/RightSidePanel.vue
@@ -0,0 +1,226 @@
+
+
+
+
+
+
+
+
+
+
+ {{ panelTitle }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ rightSidePanelStore.openPanel(newTab)
+ }
+ "
+ >
+
+ {{ tab.label() }}
+
+
+
+
+
+
+
+
+
diff --git a/src/components/rightSidePanel/info/TabInfo.vue b/src/components/rightSidePanel/info/TabInfo.vue
new file mode 100644
index 0000000000..fa13e14c4d
--- /dev/null
+++ b/src/components/rightSidePanel/info/TabInfo.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
diff --git a/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue b/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue
new file mode 100644
index 0000000000..2a4448b047
--- /dev/null
+++ b/src/components/rightSidePanel/layout/PropertiesAccordionItem.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/rightSidePanel/layout/SidePanelSearch.vue b/src/components/rightSidePanel/layout/SidePanelSearch.vue
new file mode 100644
index 0000000000..efbe22159f
--- /dev/null
+++ b/src/components/rightSidePanel/layout/SidePanelSearch.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/rightSidePanel/parameters/SectionWidgets.vue b/src/components/rightSidePanel/parameters/SectionWidgets.vue
new file mode 100644
index 0000000000..71ee6194f9
--- /dev/null
+++ b/src/components/rightSidePanel/parameters/SectionWidgets.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+ {{ displayLabel }}
+
+
+
+
+
+
diff --git a/src/components/rightSidePanel/parameters/TabParameters.vue b/src/components/rightSidePanel/parameters/TabParameters.vue
new file mode 100644
index 0000000000..1953da7817
--- /dev/null
+++ b/src/components/rightSidePanel/parameters/TabParameters.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
diff --git a/src/components/rightSidePanel/settings/TabSettings.vue b/src/components/rightSidePanel/settings/TabSettings.vue
new file mode 100644
index 0000000000..cf2aeb6a31
--- /dev/null
+++ b/src/components/rightSidePanel/settings/TabSettings.vue
@@ -0,0 +1,245 @@
+
+
+
+
+
+ {{ t('rightSidePanel.nodeState') }}
+
+
+
+
+
+
+
+ {{ t('rightSidePanel.color') }}
+
+
+
+
+
+
+
+ {{ t('rightSidePanel.pinned') }}
+
+
+
+
+
+
+
diff --git a/src/core/graph/subgraph/SubgraphNode.vue b/src/components/rightSidePanel/subgraph/SubgraphEditor.vue
similarity index 67%
rename from src/core/graph/subgraph/SubgraphNode.vue
rename to src/components/rightSidePanel/subgraph/SubgraphEditor.vue
index 7be5246f2f..8d406b76d5 100644
--- a/src/core/graph/subgraph/SubgraphNode.vue
+++ b/src/components/rightSidePanel/subgraph/SubgraphEditor.vue
@@ -1,5 +1,5 @@
+
-
-
-
-
- {{ $t('subgraphStore.shown') }}
+
-
-
-
-
-
- {{ $t('subgraphStore.showRecommended') }}
-
diff --git a/src/components/rightSidePanel/subgraph/SubgraphNodeWidget.vue b/src/components/rightSidePanel/subgraph/SubgraphNodeWidget.vue
new file mode 100644
index 0000000000..9c4b72d6a7
--- /dev/null
+++ b/src/components/rightSidePanel/subgraph/SubgraphNodeWidget.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+ {{ nodeTitle }}
+
+
{{ widgetName }}
+
+
+
+
+
+
+
diff --git a/src/components/searchbox/NodeSearchBox.vue b/src/components/searchbox/NodeSearchBox.vue
index 956147472a..abda187a5c 100644
--- a/src/components/searchbox/NodeSearchBox.vue
+++ b/src/components/searchbox/NodeSearchBox.vue
@@ -3,22 +3,24 @@
class="comfy-vue-node-search-container flex w-full min-w-96 items-center justify-center"
>
+ >
+
+
import { debounce } from 'es-toolkit/compat'
-import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -88,6 +89,7 @@ import NodePreview from '@/components/node/NodePreview.vue'
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
+import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
diff --git a/src/components/searchbox/NodeSearchFilter.vue b/src/components/searchbox/NodeSearchFilter.vue
index 7b650cebad..0f54248268 100644
--- a/src/components/searchbox/NodeSearchFilter.vue
+++ b/src/components/searchbox/NodeSearchFilter.vue
@@ -17,16 +17,16 @@
/>
diff --git a/src/components/sidebar/tabs/AssetsSidebarTab.vue b/src/components/sidebar/tabs/AssetsSidebarTab.vue
index b9e9e05ae0..827bd68516 100644
--- a/src/components/sidebar/tabs/AssetsSidebarTab.vue
+++ b/src/components/sidebar/tabs/AssetsSidebarTab.vue
@@ -1,19 +1,21 @@
-
-
-
- {{ $t('sideToolbar.mediaAssets.title') }}
-
-
+
+
+
- {{ $t('Job ID') }}:
+ {{ $t('assetBrowser.jobId') }}:
{{ folderPromptId?.substring(0, 8) }}
-
+
@@ -21,39 +23,41 @@
+
+
+
+ {{
+ $t('sideToolbar.labels.generated')
+ }}
+ {{
+ $t('sideToolbar.labels.imported')
+ }}
+
+
-
-
-
-
-
-
+
+
+
+ {{ $t('sideToolbar.backToAssets') }}
+
-
-
- {{ $t('sideToolbar.labels.generated') }}
- {{ $t('sideToolbar.labels.imported') }}
-
+
+
-
-
+
-
-
+
-
-
+ {{
isHoveringSelectionCount
? $t('mediaAsset.selection.deselectAll')
: $t('mediaAsset.selection.selectedCount', {
count: totalOutputCount
})
- "
- type="secondary"
- :class="isCompact ? 'text-left' : ''"
- @click="handleDeselectAll"
- />
+ }}
+
-
+
-
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
-
+ {{ $t('mediaAsset.selection.deleteSelected') }}
+
+
+
+ {{ $t('mediaAsset.selection.downloadSelected') }}
+
+
-
+
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'
+import { useI18n } from 'vue-i18n'
-import IconButton from '@/components/button/IconButton.vue'
-import IconTextButton from '@/components/button/IconTextButton.vue'
-import TextButton from '@/components/button/TextButton.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
+import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
-import { t } from '@/i18n'
+import Button from '@/components/ui/button/Button.vue'
import MediaAssetCard from '@/platform/assets/components/MediaAssetCard.vue'
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
@@ -193,8 +190,9 @@ import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
+import { cn } from '@/utils/tailwindUtil'
-import AssetsSidebarTemplate from './AssetSidebarTemplate.vue'
+const { t } = useI18n()
const activeTab = ref<'input' | 'output'>('output')
const folderPromptId = ref(null)
@@ -257,9 +255,11 @@ useResizeObserver(footerRef, (entries) => {
})
// Determine if we should show compact mode (icon only)
-// Threshold: 350px or less shows icon only
+// Threshold matches when grid switches from 2 columns to 1 column
+// 2 columns need about ~430px
+const COMPACT_MODE_THRESHOLD_PX = 430
const isCompact = computed(
- () => footerWidth.value > 0 && footerWidth.value <= 350
+ () => footerWidth.value > 0 && footerWidth.value <= COMPACT_MODE_THRESHOLD_PX
)
// Hover state for selection count button
diff --git a/src/components/sidebar/tabs/ModelLibrarySidebarTab.vue b/src/components/sidebar/tabs/ModelLibrarySidebarTab.vue
index 9ff7bd7638..2186552314 100644
--- a/src/components/sidebar/tabs/ModelLibrarySidebarTab.vue
+++ b/src/components/sidebar/tabs/ModelLibrarySidebarTab.vue
@@ -1,35 +1,39 @@
-
+
+ >
+
+
+ >
+
+
-
+
+
+
+
diff --git a/src/components/sidebar/tabs/modelLibrary/DownloadItem.vue b/src/components/sidebar/tabs/modelLibrary/DownloadItem.vue
index 73b840587a..6f855ede17 100644
--- a/src/components/sidebar/tabs/modelLibrary/DownloadItem.vue
+++ b/src/components/sidebar/tabs/modelLibrary/DownloadItem.vue
@@ -30,43 +30,48 @@
+ >
+
+
+ >
+
+
+ >
+
+
-
-
diff --git a/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue b/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue
index edba96e05c..fc3cabfcc5 100644
--- a/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue
+++ b/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue
@@ -18,42 +18,49 @@
#actions
>
+
-
-
-
+
+ >
+
+
+ >
+
+
@@ -67,7 +74,6 @@
diff --git a/src/components/toast/RerouteMigrationToast.vue b/src/components/toast/RerouteMigrationToast.vue
index c084926d15..e807994a3f 100644
--- a/src/components/toast/RerouteMigrationToast.vue
+++ b/src/components/toast/RerouteMigrationToast.vue
@@ -5,13 +5,9 @@
{{ t('toastMessages.migrateToLitegraphReroute') }}
-
+
+ {{ t('g.migrate') }}
+
@@ -19,10 +15,10 @@
diff --git a/src/components/topbar/ActionBarButtons.vue b/src/components/topbar/ActionBarButtons.vue
index 81b5c42ad0..832bc179b8 100644
--- a/src/components/topbar/ActionBarButtons.vue
+++ b/src/components/topbar/ActionBarButtons.vue
@@ -4,26 +4,27 @@
v-for="(button, index) in actionBarButtonStore.buttons"
:key="index"
v-tooltip.bottom="button.tooltip"
- :label="button.label"
:aria-label="button.tooltip || button.label"
:class="button.class"
- text
- rounded
- severity="secondary"
- class="h-7"
+ variant="muted-textonly"
+ size="sm"
+ class="h-7 rounded-full"
@click="button.onClick"
>
-
-
-
+
+
{{ button.label }}
diff --git a/src/components/topbar/CurrentUserButton.test.ts b/src/components/topbar/CurrentUserButton.test.ts
index 60c46ff4a1..db5349b49b 100644
--- a/src/components/topbar/CurrentUserButton.test.ts
+++ b/src/components/topbar/CurrentUserButton.test.ts
@@ -1,6 +1,6 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
-import Button from 'primevue/button'
+import Button from '@/components/ui/button/Button.vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
diff --git a/src/components/topbar/CurrentUserButton.vue b/src/components/topbar/CurrentUserButton.vue
index d64a6dfe26..39c4f1dd3f 100644
--- a/src/components/topbar/CurrentUserButton.vue
+++ b/src/components/topbar/CurrentUserButton.vue
@@ -3,9 +3,8 @@
@@ -14,22 +13,30 @@
>
-
+
-
+
diff --git a/src/components/topbar/WorkflowOverflowMenu.vue b/src/components/topbar/WorkflowOverflowMenu.vue
index 33b8475a3a..f22ebfe13f 100644
--- a/src/components/topbar/WorkflowOverflowMenu.vue
+++ b/src/components/topbar/WorkflowOverflowMenu.vue
@@ -2,13 +2,14 @@
+ >
+
+
+
+
+
+
+
+
+
diff --git a/src/components/ui/button/button.variants.ts b/src/components/ui/button/button.variants.ts
new file mode 100644
index 0000000000..2d36d17b6d
--- /dev/null
+++ b/src/components/ui/button/button.variants.ts
@@ -0,0 +1,53 @@
+import type { VariantProps } from 'cva'
+import { cva } from 'cva'
+
+export const buttonVariants = cva({
+ base: '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]:size-4 [&_svg]:shrink-0',
+ variants: {
+ variant: {
+ secondary:
+ 'bg-secondary-background text-secondary-foreground hover:bg-secondary-background-hover',
+ primary:
+ 'bg-primary-background text-base-foreground hover:bg-primary-background-hover',
+ inverted:
+ 'bg-base-foreground text-base-background hover:bg-base-foreground/80',
+ destructive:
+ 'bg-destructive-background text-base-foreground hover:bg-destructive-background-hover',
+ textonly:
+ 'text-base-foreground bg-transparent hover:bg-secondary-background-hover',
+ 'muted-textonly':
+ 'text-muted-foreground bg-transparent hover:bg-secondary-background-hover',
+ 'destructive-textonly':
+ 'text-destructive-background bg-transparent hover:bg-destructive-background/10'
+ },
+ size: {
+ sm: 'h-6 rounded-sm px-2 py-1 text-xs',
+ md: 'h-8 rounded-lg p-2 text-xs',
+ lg: 'h-10 rounded-lg px-4 py-2 text-sm',
+ icon: 'size-8',
+ 'icon-sm': 'size-5 p-0'
+ }
+ },
+
+ defaultVariants: {
+ variant: 'secondary',
+ size: 'md'
+ }
+})
+
+export type ButtonVariants = VariantProps
+
+const variants = [
+ 'secondary',
+ 'primary',
+ 'inverted',
+ 'destructive',
+ 'textonly',
+ 'muted-textonly',
+ 'destructive-textonly'
+] as const satisfies Array
+const sizes = ['sm', 'md', 'lg', 'icon', 'icon-sm'] as const satisfies Array<
+ ButtonVariants['size']
+>
+
+export const FOR_STORIES = { variants, sizes } as const
diff --git a/src/components/ui/slider/Slider.vue b/src/components/ui/slider/Slider.vue
index 2ecfc794a7..7d7a951212 100644
--- a/src/components/ui/slider/Slider.vue
+++ b/src/components/ui/slider/Slider.vue
@@ -14,6 +14,7 @@ import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<
+ // eslint-disable-next-line vue/no-unused-properties
SliderRootProps & { class?: HTMLAttributes['class'] }
>()
diff --git a/src/components/widget/SampleModelSelector.vue b/src/components/widget/SampleModelSelector.vue
index 7f0147a3b8..6e1db7e7c7 100644
--- a/src/components/widget/SampleModelSelector.vue
+++ b/src/components/widget/SampleModelSelector.vue
@@ -17,43 +17,34 @@
- {}"
- >
-
-
-
-
+ {}">
+
+ {{ $t('g.upload') }}
+
- {
close()
}
"
>
-
-
-
-
-
+ {{ $t('g.settings') }}
+
+ {
close()
}
"
>
-
-
-
-
+
+ {{ $t('g.profile') }}
+
@@ -99,12 +90,13 @@
- {}"
>
-
+
@@ -133,16 +125,15 @@
diff --git a/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts b/src/platform/cloud/subscription/composables/useSubscription.test.ts
similarity index 94%
rename from tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts
rename to src/platform/cloud/subscription/composables/useSubscription.test.ts
index 1305e9f156..9409709d3a 100644
--- a/tests-ui/tests/platform/cloud/subscription/useSubscription.test.ts
+++ b/src/platform/cloud/subscription/composables/useSubscription.test.ts
@@ -152,10 +152,28 @@ describe('useSubscription', () => {
expect(formattedRenewalDate.value).toBe('')
})
- it('should format monthly price correctly', () => {
- const { formattedMonthlyPrice } = useSubscription()
+ it('should return subscription tier from status', async () => {
+ vi.mocked(global.fetch).mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ is_active: true,
+ subscription_id: 'sub_123',
+ subscription_tier: 'CREATOR',
+ renewal_date: '2025-11-16T12:00:00Z'
+ })
+ } as Response)
- expect(formattedMonthlyPrice.value).toBe('$20')
+ mockIsLoggedIn.value = true
+ const { subscriptionTier, fetchStatus } = useSubscription()
+
+ await fetchStatus()
+ expect(subscriptionTier.value).toBe('CREATOR')
+ })
+
+ it('should return null when subscription tier is not available', () => {
+ const { subscriptionTier } = useSubscription()
+
+ expect(subscriptionTier.value).toBeNull()
})
})
diff --git a/src/platform/cloud/subscription/composables/useSubscription.ts b/src/platform/cloud/subscription/composables/useSubscription.ts
index ccd54fe5e4..816e5afbf7 100644
--- a/src/platform/cloud/subscription/composables/useSubscription.ts
+++ b/src/platform/cloud/subscription/composables/useSubscription.ts
@@ -5,27 +5,25 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
-import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
-import { useDialogService } from '@/services/dialogService'
import {
FirebaseAuthStoreError,
useFirebaseAuthStore
} from '@/stores/firebaseAuthStore'
+import { useDialogService } from '@/services/dialogService'
+import { TIER_TO_KEY } from '@/platform/cloud/subscription/constants/tierPricing'
+import type { operations } from '@/types/comfyRegistryTypes'
import { useSubscriptionCancellationWatcher } from './useSubscriptionCancellationWatcher'
-type CloudSubscriptionCheckoutResponse = {
- checkout_url: string
-}
+type CloudSubscriptionCheckoutResponse = NonNullable<
+ operations['createCloudSubscriptionCheckout']['responses']['201']['content']['application/json']
+>
-export type CloudSubscriptionStatusResponse = {
- is_active: boolean
- subscription_id: string
- renewal_date: string | null
- end_date?: string | null
-}
+export type CloudSubscriptionStatusResponse = NonNullable<
+ operations['GetCloudSubscriptionStatus']['responses']['200']['content']['application/json']
+>
function useSubscriptionInternal() {
const subscriptionStatus = ref(null)
@@ -37,7 +35,7 @@ function useSubscriptionInternal() {
return subscriptionStatus.value?.is_active ?? false
})
const { reportError, accessBillingPortal } = useFirebaseAuthActions()
- const dialogService = useDialogService()
+ const { showSubscriptionRequiredDialog } = useDialogService()
const { getAuthHeader } = useFirebaseAuthStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
@@ -53,7 +51,7 @@ function useSubscriptionInternal() {
const renewalDate = new Date(subscriptionStatus.value.renewal_date)
- return renewalDate.toLocaleDateString(undefined, {
+ return renewalDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
@@ -65,17 +63,35 @@ function useSubscriptionInternal() {
const endDate = new Date(subscriptionStatus.value.end_date)
- return endDate.toLocaleDateString(undefined, {
+ return endDate.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
- const formattedMonthlyPrice = computed(
- () => `$${MONTHLY_SUBSCRIPTION_PRICE.toFixed(0)}`
+ const subscriptionTier = computed(
+ () => subscriptionStatus.value?.subscription_tier ?? null
)
+ const subscriptionDuration = computed(
+ () => subscriptionStatus.value?.subscription_duration ?? null
+ )
+
+ const isYearlySubscription = computed(
+ () => subscriptionDuration.value === 'ANNUAL'
+ )
+
+ const subscriptionTierName = computed(() => {
+ const tier = subscriptionTier.value
+ if (!tier) return ''
+ const key = TIER_TO_KEY[tier] ?? 'standard'
+ const baseName = t(`subscription.tiers.${key}.name`)
+ return isYearlySubscription.value
+ ? t('subscription.tierNameYearly', { name: baseName })
+ : baseName
+ })
+
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
const fetchStatus = wrapWithErrorHandlingAsync(
@@ -102,7 +118,7 @@ function useSubscriptionInternal() {
useTelemetry()?.trackSubscription('modal_opened')
}
- void dialogService.showSubscriptionRequiredDialog()
+ void showSubscriptionRequiredDialog()
}
const shouldWatchCancellation = (): boolean =>
@@ -227,7 +243,11 @@ function useSubscriptionInternal() {
isCancelled,
formattedRenewalDate,
formattedEndDate,
- formattedMonthlyPrice,
+ subscriptionTier,
+ subscriptionDuration,
+ isYearlySubscription,
+ subscriptionTierName,
+ subscriptionStatus,
// Actions
subscribe,
diff --git a/tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionActions.test.ts b/src/platform/cloud/subscription/composables/useSubscriptionActions.test.ts
similarity index 71%
rename from tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionActions.test.ts
rename to src/platform/cloud/subscription/composables/useSubscriptionActions.test.ts
index 5de71bf6bf..f3f3de2957 100644
--- a/tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionActions.test.ts
+++ b/src/platform/cloud/subscription/composables/useSubscriptionActions.test.ts
@@ -7,23 +7,6 @@ const mockFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
-const mockT = vi.fn((key: string, values?: any) => {
- if (key === 'subscription.nextBillingCycle') return 'next billing cycle'
- if (key === 'subscription.refreshesOn') {
- return `Refreshes to $${values?.monthlyCreditBonusUsd} on ${values?.date}`
- }
- return key
-})
-
-vi.mock('vue-i18n', async (importOriginal) => {
- const actual = await importOriginal()
- return {
- ...actual,
- useI18n: () => ({
- t: mockT
- })
- }
-})
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: () => ({
@@ -31,12 +14,9 @@ vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
})
}))
-const mockFormattedRenewalDate = { value: '2024-12-31' }
-
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
- fetchStatus: mockFetchStatus,
- formattedRenewalDate: mockFormattedRenewalDate
+ fetchStatus: mockFetchStatus
})
}))
@@ -62,23 +42,6 @@ Object.defineProperty(window, 'open', {
describe('useSubscriptionActions', () => {
beforeEach(() => {
vi.clearAllMocks()
- mockFormattedRenewalDate.value = '2024-12-31'
- })
-
- describe('refreshTooltip', () => {
- it('should format tooltip with renewal date', () => {
- const { refreshTooltip } = useSubscriptionActions()
- expect(refreshTooltip.value).toBe('Refreshes to $10 on 2024-12-31')
- })
-
- it('should use fallback text when no renewal date', () => {
- mockFormattedRenewalDate.value = ''
- const { refreshTooltip } = useSubscriptionActions()
- expect(refreshTooltip.value).toBe(
- 'Refreshes to $10 on next billing cycle'
- )
- expect(mockT).toHaveBeenCalledWith('subscription.nextBillingCycle')
- })
})
describe('handleAddApiCredits', () => {
diff --git a/src/platform/cloud/subscription/composables/useSubscriptionActions.ts b/src/platform/cloud/subscription/composables/useSubscriptionActions.ts
index 594981ec64..7c09942f0d 100644
--- a/src/platform/cloud/subscription/composables/useSubscriptionActions.ts
+++ b/src/platform/cloud/subscription/composables/useSubscriptionActions.ts
@@ -1,5 +1,4 @@
-import { computed, onMounted, ref } from 'vue'
-import { useI18n } from 'vue-i18n'
+import { onMounted, ref } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
@@ -8,30 +7,18 @@ import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
-const MONTHLY_CREDIT_BONUS_USD = 10
-
/**
* Composable for handling subscription panel actions and loading states
*/
export function useSubscriptionActions() {
- const { t } = useI18n()
const dialogService = useDialogService()
const authActions = useFirebaseAuthActions()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
- const { fetchStatus, formattedRenewalDate } = useSubscription()
+ const { fetchStatus } = useSubscription()
const isLoadingSupport = ref(false)
- const refreshTooltip = computed(() => {
- const date =
- formattedRenewalDate.value || t('subscription.nextBillingCycle')
- return t('subscription.refreshesOn', {
- monthlyCreditBonusUsd: MONTHLY_CREDIT_BONUS_USD,
- date
- })
- })
-
onMounted(() => {
void handleRefresh()
})
@@ -72,7 +59,6 @@ export function useSubscriptionActions() {
return {
isLoadingSupport,
- refreshTooltip,
handleAddApiCredits,
handleMessageSupport,
handleRefresh,
diff --git a/tests-ui/tests/platform/cloud/subscription/useSubscriptionCancellationWatcher.test.ts b/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.test.ts
similarity index 98%
rename from tests-ui/tests/platform/cloud/subscription/useSubscriptionCancellationWatcher.test.ts
rename to src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.test.ts
index 94db275495..12e114aed2 100644
--- a/tests-ui/tests/platform/cloud/subscription/useSubscriptionCancellationWatcher.test.ts
+++ b/src/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher.test.ts
@@ -4,11 +4,12 @@ import type { EffectScope } from 'vue'
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
import { useSubscriptionCancellationWatcher } from '@/platform/cloud/subscription/composables/useSubscriptionCancellationWatcher'
+import type { TelemetryProvider } from '@/platform/telemetry/types'
describe('useSubscriptionCancellationWatcher', () => {
const trackMonthlySubscriptionCancelled = vi.fn()
const telemetryMock: Pick<
- import('@/platform/telemetry/types').TelemetryProvider,
+ TelemetryProvider,
'trackMonthlySubscriptionCancelled'
> = {
trackMonthlySubscriptionCancelled
diff --git a/tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts b/src/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts
similarity index 59%
rename from tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts
rename to src/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts
index 1d64129a40..dde7313e49 100644
--- a/tests-ui/tests/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts
+++ b/src/platform/cloud/subscription/composables/useSubscriptionCredits.test.ts
@@ -1,8 +1,28 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type * as VueI18nModule from 'vue-i18n'
+import * as comfyCredits from '@/base/credits/comfyCredits'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
+import type { operations } from '@/types/comfyRegistryTypes'
+
+type GetCustomerBalanceResponse =
+ operations['GetCustomerBalance']['responses']['200']['content']['application/json']
+
+vi.mock(
+ 'vue-i18n',
+ async (importOriginal: () => Promise) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useI18n: () => ({
+ t: () => 'Credits',
+ locale: { value: 'en-US' }
+ })
+ }
+ }
+)
// Mock Firebase Auth and related modules
vi.mock('vuefire', () => ({
@@ -55,14 +75,6 @@ vi.mock('@/stores/apiKeyAuthStore', () => ({
})
}))
-// Mock formatMetronomeCurrency
-vi.mock('@/utils/formatUtil', () => ({
- formatMetronomeCurrency: vi.fn((micros: number) => {
- // Simple mock that converts micros to dollars
- return (micros / 1000000).toFixed(2)
- })
-}))
-
describe('useSubscriptionCredits', () => {
let authStore: ReturnType
@@ -73,63 +85,66 @@ describe('useSubscriptionCredits', () => {
})
describe('totalCredits', () => {
- it('should return "0.00" when balance is null', () => {
+ it('should return "0" when balance is null', () => {
authStore.balance = null
const { totalCredits } = useSubscriptionCredits()
- expect(totalCredits.value).toBe('0.00')
+ expect(totalCredits.value).toBe('0')
})
- it('should return "0.00" when amount_micros is missing', () => {
- authStore.balance = {} as any
+ it('should return "0" when amount_micros is missing', () => {
+ authStore.balance = {} as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
- expect(totalCredits.value).toBe('0.00')
+ expect(totalCredits.value).toBe('0')
})
it('should format amount_micros correctly', () => {
- authStore.balance = { amount_micros: 5000000 } as any
+ authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
- expect(totalCredits.value).toBe('5.00')
+ expect(totalCredits.value).toBe('211')
})
- it('should handle formatting errors gracefully', async () => {
- const mockFormatMetronomeCurrency = vi.mocked(
- await import('@/utils/formatUtil')
- ).formatMetronomeCurrency
- mockFormatMetronomeCurrency.mockImplementationOnce(() => {
+ it('should handle formatting errors by throwing', async () => {
+ const formatSpy = vi.spyOn(comfyCredits, 'formatCreditsFromCents')
+ formatSpy.mockImplementationOnce(() => {
throw new Error('Formatting error')
})
- authStore.balance = { amount_micros: 5000000 } as any
+ authStore.balance = { amount_micros: 100 } as GetCustomerBalanceResponse
const { totalCredits } = useSubscriptionCredits()
- expect(totalCredits.value).toBe('0.00')
+ expect(() => totalCredits.value).toThrow('Formatting error')
+ formatSpy.mockRestore()
})
})
describe('monthlyBonusCredits', () => {
- it('should return "0.00" when cloud_credit_balance_micros is missing', () => {
- authStore.balance = {} as any
+ it('should return "0" when cloud_credit_balance_micros is missing', () => {
+ authStore.balance = {} as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits()
- expect(monthlyBonusCredits.value).toBe('0.00')
+ expect(monthlyBonusCredits.value).toBe('0')
})
it('should format cloud_credit_balance_micros correctly', () => {
- authStore.balance = { cloud_credit_balance_micros: 2500000 } as any
+ authStore.balance = {
+ cloud_credit_balance_micros: 200
+ } as GetCustomerBalanceResponse
const { monthlyBonusCredits } = useSubscriptionCredits()
- expect(monthlyBonusCredits.value).toBe('2.50')
+ expect(monthlyBonusCredits.value).toBe('422')
})
})
describe('prepaidCredits', () => {
- it('should return "0.00" when prepaid_balance_micros is missing', () => {
- authStore.balance = {} as any
+ it('should return "0" when prepaid_balance_micros is missing', () => {
+ authStore.balance = {} as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits()
- expect(prepaidCredits.value).toBe('0.00')
+ expect(prepaidCredits.value).toBe('0')
})
it('should format prepaid_balance_micros correctly', () => {
- authStore.balance = { prepaid_balance_micros: 7500000 } as any
+ authStore.balance = {
+ prepaid_balance_micros: 300
+ } as GetCustomerBalanceResponse
const { prepaidCredits } = useSubscriptionCredits()
- expect(prepaidCredits.value).toBe('7.50')
+ expect(prepaidCredits.value).toBe('633')
})
})
diff --git a/src/platform/cloud/subscription/composables/useSubscriptionCredits.ts b/src/platform/cloud/subscription/composables/useSubscriptionCredits.ts
index 0c0cf5a7f7..e67b943ad0 100644
--- a/src/platform/cloud/subscription/composables/useSubscriptionCredits.ts
+++ b/src/platform/cloud/subscription/composables/useSubscriptionCredits.ts
@@ -1,58 +1,41 @@
import { computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
-import { formatMetronomeCurrency } from '@/utils/formatUtil'
/**
* Composable for handling subscription credit calculations and formatting
*/
export function useSubscriptionCredits() {
const authStore = useFirebaseAuthStore()
+ const { locale } = useI18n()
- const totalCredits = computed(() => {
- if (!authStore.balance?.amount_micros) return '0.00'
- try {
- return formatMetronomeCurrency(authStore.balance.amount_micros, 'usd')
- } catch (error) {
- console.error(
- '[useSubscriptionCredits] Error formatting total credits:',
- error
- )
- return '0.00'
- }
- })
+ const formatBalance = (maybeCents?: number) => {
+ // Backend returns cents despite the *_micros naming convention.
+ const cents = maybeCents ?? 0
+ const amount = formatCreditsFromCents({
+ cents,
+ locale: locale.value,
+ numberOptions: {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0
+ }
+ })
+ return amount
+ }
- const monthlyBonusCredits = computed(() => {
- if (!authStore.balance?.cloud_credit_balance_micros) return '0.00'
- try {
- return formatMetronomeCurrency(
- authStore.balance.cloud_credit_balance_micros,
- 'usd'
- )
- } catch (error) {
- console.error(
- '[useSubscriptionCredits] Error formatting monthly bonus credits:',
- error
- )
- return '0.00'
- }
- })
+ const totalCredits = computed(() =>
+ formatBalance(authStore.balance?.amount_micros)
+ )
- const prepaidCredits = computed(() => {
- if (!authStore.balance?.prepaid_balance_micros) return '0.00'
- try {
- return formatMetronomeCurrency(
- authStore.balance.prepaid_balance_micros,
- 'usd'
- )
- } catch (error) {
- console.error(
- '[useSubscriptionCredits] Error formatting prepaid credits:',
- error
- )
- return '0.00'
- }
- })
+ const monthlyBonusCredits = computed(() =>
+ formatBalance(authStore.balance?.cloud_credit_balance_micros)
+ )
+
+ const prepaidCredits = computed(() =>
+ formatBalance(authStore.balance?.prepaid_balance_micros)
+ )
const isLoadingBalance = computed(() => authStore.isFetchingBalance)
diff --git a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
index 1f3542c14a..c7fae5b757 100644
--- a/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
+++ b/src/platform/cloud/subscription/composables/useSubscriptionDialog.ts
@@ -17,15 +17,22 @@ export const useSubscriptionDialog = () => {
key: DIALOG_KEY,
component: defineAsyncComponent(
() =>
- import(
- '@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue'
- )
+ import('@/platform/cloud/subscription/components/SubscriptionRequiredDialogContent.vue')
),
props: {
onClose: hide
},
dialogComponentProps: {
- style: 'width: 700px;'
+ style: 'width: min(1328px, 95vw); max-height: 90vh;',
+ pt: {
+ root: {
+ class: 'rounded-2xl bg-transparent'
+ },
+ content: {
+ class:
+ '!p-0 rounded-2xl border border-border-default bg-base-background/60 backdrop-blur-md shadow-[0_25px_80px_rgba(5,6,12,0.45)]'
+ }
+ }
}
})
}
diff --git a/src/platform/cloud/subscription/constants/tierPricing.ts b/src/platform/cloud/subscription/constants/tierPricing.ts
new file mode 100644
index 0000000000..88ed0cd7d0
--- /dev/null
+++ b/src/platform/cloud/subscription/constants/tierPricing.ts
@@ -0,0 +1,56 @@
+import type { components } from '@/types/comfyRegistryTypes'
+
+type SubscriptionTier = components['schemas']['SubscriptionTier']
+
+export type TierKey = 'standard' | 'creator' | 'pro' | 'founder'
+
+export const TIER_TO_KEY: Record = {
+ STANDARD: 'standard',
+ CREATOR: 'creator',
+ PRO: 'pro',
+ FOUNDERS_EDITION: 'founder'
+}
+
+export interface TierPricing {
+ monthly: number
+ yearly: number
+ credits: number
+ videoEstimate: number
+}
+
+export const TIER_PRICING: Record, TierPricing> = {
+ standard: { monthly: 20, yearly: 16, credits: 4200, videoEstimate: 164 },
+ creator: { monthly: 35, yearly: 28, credits: 7400, videoEstimate: 288 },
+ pro: { monthly: 100, yearly: 80, credits: 21100, videoEstimate: 821 }
+}
+
+interface TierFeatures {
+ customLoRAs: boolean
+}
+
+const TIER_FEATURES: Record = {
+ standard: { customLoRAs: false },
+ creator: { customLoRAs: true },
+ pro: { customLoRAs: true },
+ founder: { customLoRAs: false }
+}
+
+export const DEFAULT_TIER_KEY: TierKey = 'standard'
+
+const FOUNDER_MONTHLY_PRICE = 20
+const FOUNDER_MONTHLY_CREDITS = 5460
+
+export function getTierPrice(tierKey: TierKey, isYearly = false): number {
+ if (tierKey === 'founder') return FOUNDER_MONTHLY_PRICE
+ const pricing = TIER_PRICING[tierKey]
+ return isYearly ? pricing.yearly : pricing.monthly
+}
+
+export function getTierCredits(tierKey: TierKey): number {
+ if (tierKey === 'founder') return FOUNDER_MONTHLY_CREDITS
+ return TIER_PRICING[tierKey].credits
+}
+
+export function getTierFeatures(tierKey: TierKey): TierFeatures {
+ return TIER_FEATURES[tierKey]
+}
diff --git a/src/platform/cloud/subscription/utils/subscriptionTierRank.test.ts b/src/platform/cloud/subscription/utils/subscriptionTierRank.test.ts
new file mode 100644
index 0000000000..cce7ab050c
--- /dev/null
+++ b/src/platform/cloud/subscription/utils/subscriptionTierRank.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest'
+
+import { getPlanRank, isPlanDowngrade } from './subscriptionTierRank'
+
+describe('subscriptionTierRank', () => {
+ it('returns consistent order for ranked plans', () => {
+ const yearlyPro = getPlanRank({ tierKey: 'pro', billingCycle: 'yearly' })
+ const monthlyStandard = getPlanRank({
+ tierKey: 'standard',
+ billingCycle: 'monthly'
+ })
+
+ expect(yearlyPro).toBeLessThan(monthlyStandard)
+ })
+
+ it('identifies downgrades correctly', () => {
+ const result = isPlanDowngrade({
+ current: { tierKey: 'pro', billingCycle: 'yearly' },
+ target: { tierKey: 'creator', billingCycle: 'monthly' }
+ })
+
+ expect(result).toBe(true)
+ })
+
+ it('treats lateral or upgrade moves as non-downgrades', () => {
+ expect(
+ isPlanDowngrade({
+ current: { tierKey: 'standard', billingCycle: 'monthly' },
+ target: { tierKey: 'creator', billingCycle: 'monthly' }
+ })
+ ).toBe(false)
+
+ expect(
+ isPlanDowngrade({
+ current: { tierKey: 'creator', billingCycle: 'monthly' },
+ target: { tierKey: 'creator', billingCycle: 'monthly' }
+ })
+ ).toBe(false)
+ })
+
+ it('treats unknown plans (e.g., founder) as non-downgrade cases', () => {
+ const result = isPlanDowngrade({
+ current: { tierKey: 'founder', billingCycle: 'monthly' },
+ target: { tierKey: 'standard', billingCycle: 'monthly' }
+ })
+
+ expect(result).toBe(false)
+ })
+})
diff --git a/src/platform/cloud/subscription/utils/subscriptionTierRank.ts b/src/platform/cloud/subscription/utils/subscriptionTierRank.ts
new file mode 100644
index 0000000000..f85c8af915
--- /dev/null
+++ b/src/platform/cloud/subscription/utils/subscriptionTierRank.ts
@@ -0,0 +1,58 @@
+import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
+
+export type BillingCycle = 'monthly' | 'yearly'
+
+type RankedTierKey = Exclude
+type RankedPlanKey = `${BillingCycle}-${RankedTierKey}`
+
+interface PlanDescriptor {
+ tierKey: TierKey
+ billingCycle: BillingCycle
+}
+
+const PLAN_ORDER: RankedPlanKey[] = [
+ 'yearly-pro',
+ 'yearly-creator',
+ 'yearly-standard',
+ 'monthly-pro',
+ 'monthly-creator',
+ 'monthly-standard'
+]
+
+const PLAN_RANK = PLAN_ORDER.reduce>(
+ (acc, plan, index) => acc.set(plan, index),
+ new Map()
+)
+
+const toRankedPlanKey = (
+ tierKey: TierKey,
+ billingCycle: BillingCycle
+): RankedPlanKey | null => {
+ if (tierKey === 'founder') return null
+ return `${billingCycle}-${tierKey}` as RankedPlanKey
+}
+
+export const getPlanRank = ({
+ tierKey,
+ billingCycle
+}: PlanDescriptor): number => {
+ const planKey = toRankedPlanKey(tierKey, billingCycle)
+ if (!planKey) return Number.POSITIVE_INFINITY
+
+ return PLAN_RANK.get(planKey) ?? Number.POSITIVE_INFINITY
+}
+
+interface DowngradeCheckParams {
+ current: PlanDescriptor
+ target: PlanDescriptor
+}
+
+export const isPlanDowngrade = ({
+ current,
+ target
+}: DowngradeCheckParams): boolean => {
+ const currentRank = getPlanRank(current)
+ const targetRank = getPlanRank(target)
+
+ return targetRank > currentRank
+}
diff --git a/tests-ui/tests/platform/navigation/preservedQueryManager.test.ts b/src/platform/navigation/preservedQueryManager.test.ts
similarity index 100%
rename from tests-ui/tests/platform/navigation/preservedQueryManager.test.ts
rename to src/platform/navigation/preservedQueryManager.test.ts
diff --git a/tests-ui/fixtures/historyFixtures.ts b/src/platform/remote/comfyui/history/__fixtures__/historyFixtures.ts
similarity index 100%
rename from tests-ui/fixtures/historyFixtures.ts
rename to src/platform/remote/comfyui/history/__fixtures__/historyFixtures.ts
diff --git a/tests-ui/tests/platform/remote/comfyui/history/adapters/v2ToV1Adapter.test.ts b/src/platform/remote/comfyui/history/adapters/v2ToV1Adapter.test.ts
similarity index 50%
rename from tests-ui/tests/platform/remote/comfyui/history/adapters/v2ToV1Adapter.test.ts
rename to src/platform/remote/comfyui/history/adapters/v2ToV1Adapter.test.ts
index c047782a09..e9dff7d383 100644
--- a/tests-ui/tests/platform/remote/comfyui/history/adapters/v2ToV1Adapter.test.ts
+++ b/src/platform/remote/comfyui/history/adapters/v2ToV1Adapter.test.ts
@@ -10,14 +10,263 @@ import type { HistoryResponseV2 } from '@/platform/remote/comfyui/history/types/
import {
expectedV1Fixture,
historyV2Fixture
-} from '@tests-ui/fixtures/historyFixtures'
-import {
- historyV2FiveItemsSorting,
- historyV2MultipleNoTimestamp,
- historyV2WithMissingTimestamp
-} from '@tests-ui/fixtures/historySortingFixtures'
+} from '@/platform/remote/comfyui/history/__fixtures__/historyFixtures'
import type { HistoryTaskItem } from '@/platform/remote/comfyui/history/types/historyV1Types'
+const historyV2WithMissingTimestamp: HistoryResponseV2 = {
+ history: [
+ {
+ prompt_id: 'item-timestamp-1000',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-timestamp-1000',
+ extra_data: {
+ client_id: 'test-client'
+ }
+ },
+ outputs: {
+ '1': {
+ images: [{ filename: 'test1.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: [
+ [
+ 'execution_success',
+ { prompt_id: 'item-timestamp-1000', timestamp: 1000 }
+ ]
+ ]
+ }
+ },
+ {
+ prompt_id: 'item-timestamp-2000',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-timestamp-2000',
+ extra_data: {
+ client_id: 'test-client'
+ }
+ },
+ outputs: {
+ '2': {
+ images: [{ filename: 'test2.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: [
+ [
+ 'execution_success',
+ { prompt_id: 'item-timestamp-2000', timestamp: 2000 }
+ ]
+ ]
+ }
+ },
+ {
+ prompt_id: 'item-no-timestamp',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-no-timestamp',
+ extra_data: {
+ client_id: 'test-client'
+ }
+ },
+ outputs: {
+ '3': {
+ images: [{ filename: 'test3.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: []
+ }
+ }
+ ]
+}
+
+const historyV2FiveItemsSorting: HistoryResponseV2 = {
+ history: [
+ {
+ prompt_id: 'item-timestamp-3000',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-timestamp-3000',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '1': {
+ images: [{ filename: 'test1.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: [
+ [
+ 'execution_success',
+ { prompt_id: 'item-timestamp-3000', timestamp: 3000 }
+ ]
+ ]
+ }
+ },
+ {
+ prompt_id: 'item-timestamp-1000',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-timestamp-1000',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '2': {
+ images: [{ filename: 'test2.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: [
+ [
+ 'execution_success',
+ { prompt_id: 'item-timestamp-1000', timestamp: 1000 }
+ ]
+ ]
+ }
+ },
+ {
+ prompt_id: 'item-timestamp-5000',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-timestamp-5000',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '3': {
+ images: [{ filename: 'test3.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: [
+ [
+ 'execution_success',
+ { prompt_id: 'item-timestamp-5000', timestamp: 5000 }
+ ]
+ ]
+ }
+ },
+ {
+ prompt_id: 'item-timestamp-2000',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-timestamp-2000',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '4': {
+ images: [{ filename: 'test4.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: [
+ [
+ 'execution_success',
+ { prompt_id: 'item-timestamp-2000', timestamp: 2000 }
+ ]
+ ]
+ }
+ },
+ {
+ prompt_id: 'item-timestamp-4000',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-timestamp-4000',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '5': {
+ images: [{ filename: 'test5.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: [
+ [
+ 'execution_success',
+ { prompt_id: 'item-timestamp-4000', timestamp: 4000 }
+ ]
+ ]
+ }
+ }
+ ]
+}
+
+const historyV2MultipleNoTimestamp: HistoryResponseV2 = {
+ history: [
+ {
+ prompt_id: 'item-no-timestamp-1',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-no-timestamp-1',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '1': {
+ images: [{ filename: 'test1.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: []
+ }
+ },
+ {
+ prompt_id: 'item-no-timestamp-2',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-no-timestamp-2',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '2': {
+ images: [{ filename: 'test2.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: []
+ }
+ },
+ {
+ prompt_id: 'item-no-timestamp-3',
+ prompt: {
+ priority: 0,
+ prompt_id: 'item-no-timestamp-3',
+ extra_data: { client_id: 'test-client' }
+ },
+ outputs: {
+ '3': {
+ images: [{ filename: 'test3.png', type: 'output', subfolder: '' }]
+ }
+ },
+ status: {
+ status_str: 'success',
+ completed: true,
+ messages: []
+ }
+ }
+ ]
+}
+
function findResultByPromptId(
result: HistoryTaskItem[],
promptId: string
diff --git a/tests-ui/tests/platform/remote/comfyui/history/fetchers/fetchHistoryV1.test.ts b/src/platform/remote/comfyui/history/fetchers/fetchHistoryV1.test.ts
similarity index 93%
rename from tests-ui/tests/platform/remote/comfyui/history/fetchers/fetchHistoryV1.test.ts
rename to src/platform/remote/comfyui/history/fetchers/fetchHistoryV1.test.ts
index e0869778e3..b2fa1cfa2e 100644
--- a/tests-ui/tests/platform/remote/comfyui/history/fetchers/fetchHistoryV1.test.ts
+++ b/src/platform/remote/comfyui/history/fetchers/fetchHistoryV1.test.ts
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
import { fetchHistoryV1 } from '@/platform/remote/comfyui/history/fetchers/fetchHistoryV1'
-import { historyV1RawResponse } from '@tests-ui/fixtures/historyFixtures'
+import { historyV1RawResponse } from '@/platform/remote/comfyui/history/__fixtures__/historyFixtures'
describe('fetchHistoryV1', () => {
const mockFetchApi = vi.fn().mockResolvedValue({
diff --git a/tests-ui/tests/platform/remote/comfyui/history/fetchers/fetchHistoryV2.test.ts b/src/platform/remote/comfyui/history/fetchers/fetchHistoryV2.test.ts
similarity index 94%
rename from tests-ui/tests/platform/remote/comfyui/history/fetchers/fetchHistoryV2.test.ts
rename to src/platform/remote/comfyui/history/fetchers/fetchHistoryV2.test.ts
index ff0e287507..fd8aa8bbd0 100644
--- a/tests-ui/tests/platform/remote/comfyui/history/fetchers/fetchHistoryV2.test.ts
+++ b/src/platform/remote/comfyui/history/fetchers/fetchHistoryV2.test.ts
@@ -8,7 +8,7 @@ import { fetchHistoryV2 } from '@/platform/remote/comfyui/history/fetchers/fetch
import {
expectedV1Fixture,
historyV2Fixture
-} from '@tests-ui/fixtures/historyFixtures'
+} from '@/platform/remote/comfyui/history/__fixtures__/historyFixtures'
describe('fetchHistoryV2', () => {
const mockFetchApi = vi.fn().mockResolvedValue({
diff --git a/tests-ui/tests/platform/remote/comfyui/history/reconciliation.test.ts b/src/platform/remote/comfyui/history/reconciliation.test.ts
similarity index 100%
rename from tests-ui/tests/platform/remote/comfyui/history/reconciliation.test.ts
rename to src/platform/remote/comfyui/history/reconciliation.test.ts
diff --git a/src/platform/remote/comfyui/jobs/fetchJobs.test.ts b/src/platform/remote/comfyui/jobs/fetchJobs.test.ts
new file mode 100644
index 0000000000..f3b2ad5a82
--- /dev/null
+++ b/src/platform/remote/comfyui/jobs/fetchJobs.test.ts
@@ -0,0 +1,291 @@
+import { describe, expect, it, vi } from 'vitest'
+
+import {
+ extractWorkflow,
+ fetchHistory,
+ fetchJobDetail,
+ fetchQueue
+} from '@/platform/remote/comfyui/jobs/fetchJobs'
+import type {
+ RawJobListItem,
+ zJobsListResponse
+} from '@/platform/remote/comfyui/jobs/jobTypes'
+import type { z } from 'zod'
+
+type JobsListResponse = z.infer
+
+function createMockJob(
+ id: string,
+ status: 'pending' | 'in_progress' | 'completed' = 'completed',
+ overrides: Partial = {}
+): RawJobListItem {
+ return {
+ id,
+ status,
+ create_time: Date.now(),
+ execution_start_time: null,
+ execution_end_time: null,
+ preview_output: null,
+ outputs_count: 0,
+ ...overrides
+ }
+}
+
+function createMockResponse(
+ jobs: RawJobListItem[],
+ total: number = jobs.length
+): JobsListResponse {
+ return {
+ jobs,
+ pagination: {
+ offset: 0,
+ limit: 200,
+ total,
+ has_more: false
+ }
+ }
+}
+
+describe('fetchJobs', () => {
+ describe('fetchHistory', () => {
+ it('fetches completed jobs', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve(
+ createMockResponse([
+ createMockJob('job1', 'completed'),
+ createMockJob('job2', 'completed')
+ ])
+ )
+ })
+
+ const result = await fetchHistory(mockFetch)
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/jobs?status=completed&limit=200&offset=0'
+ )
+ expect(result).toHaveLength(2)
+ expect(result[0].id).toBe('job1')
+ expect(result[1].id).toBe('job2')
+ })
+
+ it('assigns synthetic priorities', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve(
+ createMockResponse(
+ [
+ createMockJob('job1', 'completed'),
+ createMockJob('job2', 'completed'),
+ createMockJob('job3', 'completed')
+ ],
+ 3
+ )
+ )
+ })
+
+ const result = await fetchHistory(mockFetch)
+
+ // Priority should be assigned from total down
+ expect(result[0].priority).toBe(3) // total - 0 - 0
+ expect(result[1].priority).toBe(2) // total - 0 - 1
+ expect(result[2].priority).toBe(1) // total - 0 - 2
+ })
+
+ it('calculates priority correctly with non-zero offset', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve(
+ createMockResponse(
+ [
+ createMockJob('job4', 'completed'),
+ createMockJob('job5', 'completed')
+ ],
+ 10 // total of 10 jobs
+ )
+ )
+ })
+
+ // Fetch page 2 (offset=5)
+ const result = await fetchHistory(mockFetch, 200, 5)
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/jobs?status=completed&limit=200&offset=5'
+ )
+ // Priority base is total - offset = 10 - 5 = 5
+ expect(result[0].priority).toBe(5) // (total - offset) - 0
+ expect(result[1].priority).toBe(4) // (total - offset) - 1
+ })
+
+ it('preserves server-provided priority', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve(
+ createMockResponse([
+ createMockJob('job1', 'completed', { priority: 999 })
+ ])
+ )
+ })
+
+ const result = await fetchHistory(mockFetch)
+
+ expect(result[0].priority).toBe(999)
+ })
+
+ it('returns empty array on error', async () => {
+ const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
+
+ const result = await fetchHistory(mockFetch)
+
+ expect(result).toEqual([])
+ })
+
+ it('returns empty array on non-ok response', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 500
+ })
+
+ const result = await fetchHistory(mockFetch)
+
+ expect(result).toEqual([])
+ })
+ })
+
+ describe('fetchQueue', () => {
+ it('fetches running and pending jobs', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve(
+ createMockResponse([
+ createMockJob('running1', 'in_progress'),
+ createMockJob('pending1', 'pending'),
+ createMockJob('pending2', 'pending')
+ ])
+ )
+ })
+
+ const result = await fetchQueue(mockFetch)
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/jobs?status=in_progress,pending&limit=200&offset=0'
+ )
+ expect(result.Running).toHaveLength(1)
+ expect(result.Pending).toHaveLength(2)
+ expect(result.Running[0].id).toBe('running1')
+ expect(result.Pending[0].id).toBe('pending1')
+ })
+
+ it('assigns queue priorities above history', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () =>
+ Promise.resolve(
+ createMockResponse([
+ createMockJob('running1', 'in_progress'),
+ createMockJob('pending1', 'pending')
+ ])
+ )
+ })
+
+ const result = await fetchQueue(mockFetch)
+
+ // Queue priorities should be above 1_000_000 (QUEUE_PRIORITY_BASE)
+ expect(result.Running[0].priority).toBeGreaterThan(1_000_000)
+ expect(result.Pending[0].priority).toBeGreaterThan(1_000_000)
+ // Pending should have higher priority than running
+ expect(result.Pending[0].priority).toBeGreaterThan(
+ result.Running[0].priority
+ )
+ })
+
+ it('returns empty arrays on error', async () => {
+ const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
+
+ const result = await fetchQueue(mockFetch)
+
+ expect(result).toEqual({ Running: [], Pending: [] })
+ })
+ })
+
+ describe('fetchJobDetail', () => {
+ it('fetches job detail by id', async () => {
+ const jobDetail = {
+ ...createMockJob('job1', 'completed'),
+ workflow: { extra_data: { extra_pnginfo: { workflow: {} } } },
+ outputs: {
+ '1': {
+ images: [{ filename: 'test.png', subfolder: '', type: 'output' }]
+ }
+ }
+ }
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve(jobDetail)
+ })
+
+ const result = await fetchJobDetail(mockFetch, 'job1')
+
+ expect(mockFetch).toHaveBeenCalledWith('/jobs/job1')
+ expect(result?.id).toBe('job1')
+ expect(result?.outputs).toBeDefined()
+ })
+
+ it('returns undefined for non-ok response', async () => {
+ const mockFetch = vi.fn().mockResolvedValue({
+ ok: false,
+ status: 404
+ })
+
+ const result = await fetchJobDetail(mockFetch, 'nonexistent')
+
+ expect(result).toBeUndefined()
+ })
+
+ it('returns undefined on error', async () => {
+ const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'))
+
+ const result = await fetchJobDetail(mockFetch, 'job1')
+
+ expect(result).toBeUndefined()
+ })
+ })
+
+ describe('extractWorkflow', () => {
+ it('extracts workflow from nested structure', () => {
+ const jobDetail = {
+ ...createMockJob('job1', 'completed'),
+ workflow: {
+ extra_data: {
+ extra_pnginfo: {
+ workflow: { nodes: [], links: [] }
+ }
+ }
+ }
+ }
+
+ const workflow = extractWorkflow(jobDetail)
+
+ expect(workflow).toEqual({ nodes: [], links: [] })
+ })
+
+ it('returns undefined if workflow not present', () => {
+ const jobDetail = createMockJob('job1', 'completed')
+
+ const workflow = extractWorkflow(jobDetail)
+
+ expect(workflow).toBeUndefined()
+ })
+
+ it('returns undefined for undefined input', () => {
+ const workflow = extractWorkflow(undefined)
+
+ expect(workflow).toBeUndefined()
+ })
+ })
+})
diff --git a/src/platform/remote/comfyui/jobs/fetchJobs.ts b/src/platform/remote/comfyui/jobs/fetchJobs.ts
new file mode 100644
index 0000000000..136f683d65
--- /dev/null
+++ b/src/platform/remote/comfyui/jobs/fetchJobs.ts
@@ -0,0 +1,146 @@
+/**
+ * @fileoverview Jobs API Fetchers
+ * @module platform/remote/comfyui/jobs/fetchJobs
+ *
+ * Unified jobs API fetcher for history, queue, and job details.
+ * All distributions use the /jobs endpoint.
+ */
+
+import type { PromptId } from '@/schemas/apiSchema'
+
+import type {
+ JobDetail,
+ JobListItem,
+ JobStatus,
+ RawJobListItem
+} from './jobTypes'
+import { zJobDetail, zJobsListResponse, zWorkflowContainer } from './jobTypes'
+
+interface FetchJobsRawResult {
+ jobs: RawJobListItem[]
+ total: number
+ offset: number
+}
+
+/**
+ * Fetches raw jobs from /jobs endpoint
+ * @internal
+ */
+async function fetchJobsRaw(
+ fetchApi: (url: string) => Promise,
+ statuses: JobStatus[],
+ maxItems: number = 200,
+ offset: number = 0
+): Promise {
+ const statusParam = statuses.join(',')
+ const url = `/jobs?status=${statusParam}&limit=${maxItems}&offset=${offset}`
+ try {
+ const res = await fetchApi(url)
+ if (!res.ok) {
+ console.error(`[Jobs API] Failed to fetch jobs: ${res.status}`)
+ return { jobs: [], total: 0, offset: 0 }
+ }
+ const data = zJobsListResponse.parse(await res.json())
+ return { jobs: data.jobs, total: data.pagination.total, offset }
+ } catch (error) {
+ console.error('[Jobs API] Error fetching jobs:', error)
+ return { jobs: [], total: 0, offset: 0 }
+ }
+}
+
+// Large offset to ensure running/pending jobs sort above history
+const QUEUE_PRIORITY_BASE = 1_000_000
+
+/**
+ * Assigns synthetic priority to jobs.
+ * Only assigns if job doesn't already have a server-provided priority.
+ */
+function assignPriority(
+ jobs: RawJobListItem[],
+ basePriority: number
+): JobListItem[] {
+ return jobs.map((job, index) => ({
+ ...job,
+ priority: job.priority ?? basePriority - index
+ }))
+}
+
+/**
+ * Fetches history (completed jobs)
+ * Assigns synthetic priority starting from total (lower than queue jobs).
+ */
+export async function fetchHistory(
+ fetchApi: (url: string) => Promise,
+ maxItems: number = 200,
+ offset: number = 0
+): Promise {
+ const { jobs, total } = await fetchJobsRaw(
+ fetchApi,
+ ['completed'],
+ maxItems,
+ offset
+ )
+ // History gets priority based on total count (lower than queue)
+ return assignPriority(jobs, total - offset)
+}
+
+/**
+ * Fetches queue (in_progress + pending jobs)
+ * Pending jobs get highest priority, then running jobs.
+ */
+export async function fetchQueue(
+ fetchApi: (url: string) => Promise
+): Promise<{ Running: JobListItem[]; Pending: JobListItem[] }> {
+ const { jobs } = await fetchJobsRaw(
+ fetchApi,
+ ['in_progress', 'pending'],
+ 200,
+ 0
+ )
+
+ const running = jobs.filter((j) => j.status === 'in_progress')
+ const pending = jobs.filter((j) => j.status === 'pending')
+
+ // Pending gets highest priority, then running
+ // Both are above any history job due to QUEUE_PRIORITY_BASE
+ return {
+ Running: assignPriority(running, QUEUE_PRIORITY_BASE + running.length),
+ Pending: assignPriority(
+ pending,
+ QUEUE_PRIORITY_BASE + running.length + pending.length
+ )
+ }
+}
+
+/**
+ * Fetches full job details from /jobs/{job_id}
+ */
+export async function fetchJobDetail(
+ fetchApi: (url: string) => Promise,
+ promptId: PromptId
+): Promise {
+ try {
+ const res = await fetchApi(`/jobs/${encodeURIComponent(promptId)}`)
+
+ if (!res.ok) {
+ console.warn(`Job not found for prompt ${promptId}`)
+ return undefined
+ }
+
+ return zJobDetail.parse(await res.json())
+ } catch (error) {
+ console.error(`Failed to fetch job detail for prompt ${promptId}:`, error)
+ return undefined
+ }
+}
+
+/**
+ * Extracts workflow from job detail response.
+ * The workflow is nested at: workflow.extra_data.extra_pnginfo.workflow
+ * Full workflow validation happens downstream via validateComfyWorkflow.
+ */
+export function extractWorkflow(job: JobDetail | undefined): unknown {
+ const parsed = zWorkflowContainer.safeParse(job?.workflow)
+ if (!parsed.success) return undefined
+ return parsed.data.extra_data?.extra_pnginfo?.workflow
+}
diff --git a/src/platform/remote/comfyui/jobs/jobTypes.ts b/src/platform/remote/comfyui/jobs/jobTypes.ts
new file mode 100644
index 0000000000..8553483095
--- /dev/null
+++ b/src/platform/remote/comfyui/jobs/jobTypes.ts
@@ -0,0 +1,107 @@
+/**
+ * @fileoverview Jobs API types - Backend job API format
+ * @module platform/remote/comfyui/jobs/jobTypes
+ *
+ * These types represent the jobs API format returned by the backend.
+ * Jobs API provides a memory-optimized alternative to history API.
+ */
+
+import { z } from 'zod'
+
+import { resultItemType, zTaskOutput } from '@/schemas/apiSchema'
+
+const zJobStatus = z.enum([
+ 'pending',
+ 'in_progress',
+ 'completed',
+ 'failed',
+ 'cancelled'
+])
+
+const zPreviewOutput = z.object({
+ filename: z.string(),
+ subfolder: z.string(),
+ type: resultItemType
+})
+
+/**
+ * Execution error details for error jobs.
+ * Contains the same structure as ExecutionErrorWsMessage from WebSocket.
+ */
+const zExecutionError = z
+ .object({
+ prompt_id: z.string().optional(),
+ timestamp: z.number().optional(),
+ node_id: z.string(),
+ node_type: z.string(),
+ executed: z.array(z.string()).optional(),
+ exception_message: z.string(),
+ exception_type: z.string(),
+ traceback: z.array(z.string()),
+ current_inputs: z.unknown(),
+ current_outputs: z.unknown()
+ })
+ .passthrough()
+
+/**
+ * Raw job from API - uses passthrough to allow extra fields
+ */
+const zRawJobListItem = z
+ .object({
+ id: z.string(),
+ status: zJobStatus,
+ create_time: z.number(),
+ execution_start_time: z.number().nullable().optional(),
+ execution_end_time: z.number().nullable().optional(),
+ preview_output: zPreviewOutput.nullable().optional(),
+ outputs_count: z.number().nullable().optional(),
+ execution_error: zExecutionError.nullable().optional(),
+ workflow_id: z.string().nullable().optional(),
+ priority: z.number().optional()
+ })
+ .passthrough()
+
+/**
+ * Job detail - returned by GET /api/jobs/{job_id} (detail endpoint)
+ * Includes full workflow and outputs for re-execution and downloads
+ */
+export const zJobDetail = zRawJobListItem
+ .extend({
+ workflow: z.unknown().optional(),
+ outputs: zTaskOutput.optional(),
+ update_time: z.number().optional(),
+ execution_status: z.unknown().optional(),
+ execution_meta: z.unknown().optional()
+ })
+ .passthrough()
+
+const zPaginationInfo = z.object({
+ offset: z.number(),
+ limit: z.number(),
+ total: z.number(),
+ has_more: z.boolean()
+})
+
+export const zJobsListResponse = z.object({
+ jobs: z.array(zRawJobListItem),
+ pagination: zPaginationInfo
+})
+
+/** Schema for workflow container structure in job detail responses */
+export const zWorkflowContainer = z.object({
+ extra_data: z
+ .object({
+ extra_pnginfo: z
+ .object({
+ workflow: z.unknown()
+ })
+ .optional()
+ })
+ .optional()
+})
+
+export type JobStatus = z.infer
+export type RawJobListItem = z.infer
+/** Job list item with priority always set (server-provided or synthetic) */
+export type JobListItem = RawJobListItem & { priority: number }
+export type JobDetail = z.infer
diff --git a/src/platform/remoteConfig/refreshRemoteConfig.ts b/src/platform/remoteConfig/refreshRemoteConfig.ts
new file mode 100644
index 0000000000..5000f20142
--- /dev/null
+++ b/src/platform/remoteConfig/refreshRemoteConfig.ts
@@ -0,0 +1,23 @@
+import { api } from '@/scripts/api'
+
+import { remoteConfig } from './remoteConfig'
+
+export async function refreshRemoteConfig(): Promise {
+ try {
+ const response = await api.fetchApi('/features', { cache: 'no-store' })
+ if (response.ok) {
+ const config = await response.json()
+ window.__CONFIG__ = config
+ remoteConfig.value = config
+ return
+ }
+
+ console.warn('Failed to load remote config:', response.statusText)
+ if (response.status === 401 || response.status === 403) {
+ window.__CONFIG__ = {}
+ remoteConfig.value = {}
+ }
+ } catch (error) {
+ console.error('Failed to fetch remote config:', error)
+ }
+}
diff --git a/src/platform/remoteConfig/types.ts b/src/platform/remoteConfig/types.ts
index e33cde3095..cbca526bf8 100644
--- a/src/platform/remoteConfig/types.ts
+++ b/src/platform/remoteConfig/types.ts
@@ -34,4 +34,9 @@ export type RemoteConfig = {
comfy_platform_base_url?: string
firebase_config?: FirebaseRuntimeConfig
telemetry_disabled_events?: TelemetryEventName[]
+ model_upload_button_enabled?: boolean
+ asset_update_options_enabled?: boolean
+ private_models_enabled?: boolean
+ onboarding_survey_enabled?: boolean
+ huggingface_model_import_enabled?: boolean
}
diff --git a/src/platform/settings/components/ColorPaletteMessage.vue b/src/platform/settings/components/ColorPaletteMessage.vue
index fbdb79ce09..d98d6f6e61 100644
--- a/src/platform/settings/components/ColorPaletteMessage.vue
+++ b/src/platform/settings/components/ColorPaletteMessage.vue
@@ -13,25 +13,30 @@
option-value="id"
/>
+ >
+
+
+ >
+
+
+ >
+
+
@@ -39,10 +44,10 @@
diff --git a/src/platform/updates/components/WhatsNewPopup.stories.ts b/src/platform/updates/components/WhatsNewPopup.stories.ts
new file mode 100644
index 0000000000..e68c42dc8d
--- /dev/null
+++ b/src/platform/updates/components/WhatsNewPopup.stories.ts
@@ -0,0 +1,211 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import type { ReleaseNote } from '../common/releaseService'
+import { useReleaseStore } from '../common/releaseStore'
+import WhatsNewPopup from './WhatsNewPopup.vue'
+
+// Mock release data with realistic CMS content
+const mockReleases: ReleaseNote[] = [
+ {
+ id: 1,
+ project: 'comfyui',
+ version: '1.2.3',
+ attention: 'medium',
+ published_at: '2024-01-15T10:00:00Z',
+ content: `# ComfyUI 1.2.3 Release
+
+**What's new**
+
+New features and improvements for better workflow management.
+
+- **Enhanced Node Editor**: Improved performance for large workflows with 100+ nodes
+- **Auto-save Feature**: Your work is now automatically saved every 30 seconds
+- **New Model Support**: Added support for FLUX.1-dev and FLUX.1-schnell models
+- **Bug Fixes**: Resolved memory leak issues in the backend processing`
+ },
+ {
+ id: 2,
+ project: 'comfyui',
+ version: '1.2.4',
+ attention: 'high',
+ published_at: '2024-02-01T14:30:00Z',
+ content: `
+
+# ComfyUI 1.2.4 Major Release
+
+**What's new**
+
+Revolutionary updates that change how you create with ComfyUI.
+
+- **Real-time Collaboration**: Share and edit workflows with your team in real-time
+- **Advanced Upscaling**: New ESRGAN and Real-ESRGAN models built-in
+- **Custom Node Store**: Browse and install community nodes directly from the interface
+- **Performance Boost**: 40% faster generation times for SDXL models
+- **Dark Mode**: Beautiful new dark interface theme`
+ },
+ {
+ id: 3,
+ project: 'comfyui',
+ version: '1.3.0',
+ attention: 'high',
+ published_at: '2024-03-10T09:15:00Z',
+ content: `
+
+# ComfyUI 1.3.0 - The Biggest Update Yet
+
+**What's new**
+
+Introducing powerful new features that unlock creative possibilities.
+
+- **AI-Powered Node Suggestions**: Get intelligent recommendations while building workflows
+- **Workflow Templates**: Start from professionally designed templates
+- **Advanced Queuing**: Batch process multiple generations with queue management
+- **Mobile Preview**: Preview your workflows on mobile devices
+- **API Improvements**: Enhanced REST API with better documentation
+- **Community Hub**: Share workflows and discover creations from other users`
+ }
+]
+
+interface StoryArgs {
+ releaseData: ReleaseNote
+}
+
+const meta: Meta = {
+ title: 'Platform/Updates/WhatsNewPopup',
+ component: WhatsNewPopup,
+ parameters: {
+ layout: 'fullscreen',
+ backgrounds: { default: 'dark' }
+ },
+ argTypes: {
+ releaseData: {
+ control: 'object',
+ description: 'Release data with version and markdown content'
+ }
+ },
+ decorators: [
+ (_story, context) => {
+ // Set up the store with mock data for this story
+ const releaseStore = useReleaseStore()
+
+ // Override store data with story args
+ releaseStore.releases = [context.args.releaseData]
+
+ // Force the computed properties to return the values we want
+ Object.defineProperty(releaseStore, 'recentRelease', {
+ value: context.args.releaseData,
+ writable: true
+ })
+ Object.defineProperty(releaseStore, 'shouldShowPopup', {
+ value: true,
+ writable: true
+ })
+
+ // Mock the store methods to prevent errors
+ releaseStore.handleWhatsNewSeen = async () => {
+ // Mock implementation for Storybook
+ }
+
+ return {
+ template: `
+
+
+
+ `
+ }
+ }
+ ]
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ args: {
+ releaseData: mockReleases[0]
+ }
+}
+
+export const WithImage: Story = {
+ args: {
+ releaseData: mockReleases[1]
+ }
+}
+
+export const MajorRelease: Story = {
+ args: {
+ releaseData: mockReleases[2]
+ }
+}
+
+export const LongContent: Story = {
+ args: {
+ releaseData: {
+ id: 4,
+ project: 'comfyui',
+ version: '2.0.0',
+ attention: 'high',
+ published_at: '2024-04-20T16:00:00Z',
+ content: `
+
+# ComfyUI 2.0.0 - Complete Rewrite
+
+**What's new**
+
+The most significant update in ComfyUI history with complete platform rewrite.
+
+## Core Engine Improvements
+
+- **Next-Generation Workflow Engine**: Completely rewritten from the ground up with 500% performance improvements for complex workflows
+- **Advanced Memory Management**: Intelligent memory allocation reducing VRAM usage by up to 60% while maintaining quality
+- **Multi-Threading Support**: Full multi-core CPU utilization for preprocessing and post-processing tasks
+- **GPU Optimization**: Advanced GPU scheduling with automatic optimization for different hardware configurations
+
+## New User Interface
+
+- **Modern Design Language**: Beautiful new interface with improved accessibility and mobile responsiveness
+- **Customizable Workspace**: Fully customizable layout with dockable panels and saved workspace configurations
+- **Advanced Node Browser**: Intelligent node search with AI-powered suggestions and visual node previews
+- **Real-time Preview**: Live preview of changes as you build your workflow without needing to execute
+
+## Professional Features
+
+- **Version Control Integration**: Native Git integration for workflow version control and collaboration
+- **Enterprise Security**: Advanced security features including end-to-end encryption and audit logging
+- **Scalable Architecture**: Designed to handle enterprise-scale deployments with thousands of concurrent users
+- **Plugin Ecosystem**: Robust plugin system with hot-loading and automatic dependency management`
+ }
+ }
+}
+
+export const MinimalContent: Story = {
+ args: {
+ releaseData: {
+ id: 5,
+ project: 'comfyui',
+ version: '1.0.1',
+ attention: 'low',
+ published_at: '2024-01-05T12:00:00Z',
+ content: `# ComfyUI 1.0.1
+
+**What's new**
+
+Quick patch release.
+
+- **Bug Fix**: Fixed critical save issue`
+ }
+ }
+}
+
+export const EmptyContent: Story = {
+ args: {
+ releaseData: {
+ id: 6,
+ project: 'comfyui',
+ version: '1.0.0',
+ attention: 'low',
+ published_at: '2024-01-01T00:00:00Z',
+ content: ''
+ }
+ }
+}
diff --git a/src/platform/updates/components/WhatsNewPopup.test.ts b/src/platform/updates/components/WhatsNewPopup.test.ts
new file mode 100644
index 0000000000..f40399730f
--- /dev/null
+++ b/src/platform/updates/components/WhatsNewPopup.test.ts
@@ -0,0 +1,213 @@
+import type { VueWrapper } from '@vue/test-utils'
+import { mount } from '@vue/test-utils'
+import Button from '@/components/ui/button/Button.vue'
+import PrimeVue from 'primevue/config'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { ReleaseNote } from '../common/releaseService'
+import WhatsNewPopup from './WhatsNewPopup.vue'
+
+// Mock dependencies
+const mockTranslations: Record = {
+ 'g.close': 'Close',
+ 'whatsNewPopup.later': 'Later',
+ 'whatsNewPopup.learnMore': 'Learn More',
+ 'whatsNewPopup.noReleaseNotes': 'No release notes available'
+}
+
+vi.mock('@/i18n', () => ({
+ i18n: {
+ global: {
+ locale: {
+ value: 'en'
+ }
+ }
+ },
+ t: (key: string, params?: Record) => {
+ return params
+ ? `${mockTranslations[key] || key}:${JSON.stringify(params)}`
+ : mockTranslations[key] || key
+ },
+ d: (date: Date) => date.toLocaleDateString()
+}))
+
+vi.mock('vue-i18n', () => ({
+ useI18n: vi.fn(() => ({
+ locale: { value: 'en' },
+ t: vi.fn((key: string) => {
+ return mockTranslations[key] || key
+ })
+ }))
+}))
+
+vi.mock('@/utils/formatUtil', () => ({
+ formatVersionAnchor: vi.fn((version: string) => version.replace(/\./g, ''))
+}))
+
+vi.mock('@/utils/markdownRendererUtil', () => ({
+ renderMarkdownToHtml: vi.fn((content: string) => `${content}
`)
+}))
+
+// Mock release store
+const mockReleaseStore = {
+ recentRelease: null as ReleaseNote | null,
+ shouldShowPopup: false,
+ handleWhatsNewSeen: vi.fn(),
+ releases: [] as ReleaseNote[],
+ fetchReleases: vi.fn()
+}
+
+vi.mock('../common/releaseStore', () => ({
+ useReleaseStore: vi.fn(() => mockReleaseStore)
+}))
+
+describe('WhatsNewPopup', () => {
+ let wrapper: VueWrapper
+
+ const mountComponent = (props = {}) => {
+ return mount(WhatsNewPopup, {
+ global: {
+ plugins: [PrimeVue],
+ components: { Button },
+ mocks: {
+ $t: (key: string) => {
+ return mockTranslations[key] || key
+ }
+ },
+ stubs: {
+ // Stub Lucide icons
+ 'i-lucide-x': true,
+ 'i-lucide-external-link': true
+ }
+ },
+ props
+ })
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ // Reset store state
+ mockReleaseStore.recentRelease = null
+ mockReleaseStore.shouldShowPopup = false
+ mockReleaseStore.releases = []
+ mockReleaseStore.handleWhatsNewSeen = vi.fn()
+ mockReleaseStore.fetchReleases = vi.fn()
+ })
+
+ it('renders correctly when shouldShow is true', () => {
+ mockReleaseStore.shouldShowPopup = true
+ mockReleaseStore.recentRelease = {
+ version: '1.2.3',
+ content: '# Test Release\n\nSome content'
+ } as ReleaseNote
+
+ wrapper = mountComponent()
+ expect(wrapper.find('.whats-new-popup').exists()).toBe(true)
+ })
+
+ it('does not render when shouldShow is false', () => {
+ mockReleaseStore.shouldShowPopup = false
+ wrapper = mountComponent()
+ expect(wrapper.find('.whats-new-popup').exists()).toBe(false)
+ })
+
+ it('calls handleWhatsNewSeen when close button is clicked', async () => {
+ mockReleaseStore.shouldShowPopup = true
+ mockReleaseStore.recentRelease = {
+ version: '1.2.3',
+ content: '# Test Release'
+ } as ReleaseNote
+
+ wrapper = mountComponent()
+
+ const closeButton = wrapper.findComponent(Button)
+ await closeButton.trigger('click')
+
+ expect(mockReleaseStore.handleWhatsNewSeen).toHaveBeenCalledWith('1.2.3')
+ })
+
+ it('generates correct changelog URL', () => {
+ mockReleaseStore.shouldShowPopup = true
+ mockReleaseStore.recentRelease = {
+ version: '1.2.3',
+ content: '# Test Release'
+ } as ReleaseNote
+
+ wrapper = mountComponent()
+
+ const learnMoreLink = wrapper.find('.learn-more-link')
+ expect(learnMoreLink.attributes('href')).toContain(
+ 'docs.comfy.org/changelog'
+ )
+ })
+
+ it('handles missing release content gracefully', () => {
+ mockReleaseStore.shouldShowPopup = true
+ mockReleaseStore.recentRelease = {
+ version: '1.2.3',
+ content: ''
+ } as ReleaseNote
+
+ wrapper = mountComponent()
+
+ // Should render fallback content
+ const contentElement = wrapper.find('.content-text')
+ expect(contentElement.exists()).toBe(true)
+ })
+
+ it('emits whats-new-dismissed event when popup is closed', async () => {
+ mockReleaseStore.shouldShowPopup = true
+ mockReleaseStore.recentRelease = {
+ version: '1.2.3',
+ content: '# Test Release'
+ } as ReleaseNote
+
+ wrapper = mountComponent()
+
+ // Call the close method directly instead of triggering DOM event
+ await (wrapper.vm as any).closePopup()
+
+ expect(wrapper.emitted('whats-new-dismissed')).toBeTruthy()
+ })
+
+ it('fetches releases on mount when not already loaded', async () => {
+ mockReleaseStore.shouldShowPopup = true
+ mockReleaseStore.releases = [] // Empty releases array
+
+ wrapper = mountComponent()
+
+ expect(mockReleaseStore.fetchReleases).toHaveBeenCalled()
+ })
+
+ it('does not fetch releases when already loaded', async () => {
+ mockReleaseStore.shouldShowPopup = true
+ mockReleaseStore.releases = [{ version: '1.0.0' } as ReleaseNote] // Non-empty releases array
+
+ wrapper = mountComponent()
+
+ expect(mockReleaseStore.fetchReleases).not.toHaveBeenCalled()
+ })
+
+ it('processes markdown content correctly', async () => {
+ const mockMarkdownRendererModule = (await vi.importMock(
+ '@/utils/markdownRendererUtil'
+ )) as { renderMarkdownToHtml: ReturnType }
+ const mockMarkdownRenderer = vi.mocked(
+ mockMarkdownRendererModule.renderMarkdownToHtml
+ )
+ mockMarkdownRenderer.mockReturnValue('Processed Content ')
+
+ mockReleaseStore.shouldShowPopup = true
+ mockReleaseStore.recentRelease = {
+ version: '1.2.3',
+ content: '# Original Title\n\nContent'
+ } as ReleaseNote
+
+ wrapper = mountComponent()
+
+ // Should call markdown renderer with original content (no modification)
+ expect(mockMarkdownRenderer).toHaveBeenCalledWith(
+ '# Original Title\n\nContent'
+ )
+ })
+})
diff --git a/src/platform/updates/components/WhatsNewPopup.vue b/src/platform/updates/components/WhatsNewPopup.vue
index 421ccfb560..511d053c2f 100644
--- a/src/platform/updates/components/WhatsNewPopup.vue
+++ b/src/platform/updates/components/WhatsNewPopup.vue
@@ -1,62 +1,49 @@
-