diff --git a/.claude/commands/setup_repo.md b/.claude/commands/setup_repo.md
index d82e22ec6..71dee96a5 100644
--- a/.claude/commands/setup_repo.md
+++ b/.claude/commands/setup_repo.md
@@ -122,7 +122,7 @@ echo " pnpm build - Build for production"
echo " pnpm test:unit - Run unit tests"
echo " pnpm typecheck - Run TypeScript checks"
echo " pnpm lint - Run ESLint"
-echo " pnpm format - Format code with Prettier"
+echo " pnpm format - Format code with oxfmt"
echo ""
echo "Next steps:"
echo "1. Run 'pnpm dev' to start developing"
diff --git a/.cursor/rules/unit-test.mdc b/.cursor/rules/unit-test.mdc
deleted file mode 100644
index 2c6704f3e..000000000
--- a/.cursor/rules/unit-test.mdc
+++ /dev/null
@@ -1,21 +0,0 @@
----
-description: Creating unit tests
-globs:
-alwaysApply: false
----
-
-# Creating unit tests
-
-- This project uses `vitest` for unit testing
-- Tests are stored in the `test/` directory
-- Tests should be cross-platform compatible; able to run on Windows, macOS, and linux
- - e.g. the use of `path.resolve`, or `path.join` and `path.sep` to ensure that tests work the same on all platforms
-- Tests should be mocked properly
- - Mocks should be cleanly written and easy to understand
- - Mocks should be re-usable where possible
-
-## Unit test style
-
-- Prefer the use of `test.extend` over loose variables
- - To achieve this, import `test as baseTest` from `vitest`
-- Never use `it`; `test` should be used in place of this
\ No newline at end of file
diff --git a/.cursorrules b/.cursorrules
deleted file mode 100644
index 6f43623a3..000000000
--- 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/common/StartupDisplay.vue b/apps/desktop-ui/src/components/common/StartupDisplay.vue
index bd42c14e4..7bde5ec86 100644
--- a/apps/desktop-ui/src/components/common/StartupDisplay.vue
+++ b/apps/desktop-ui/src/components/common/StartupDisplay.vue
@@ -22,7 +22,11 @@
{{ title }}
-
+
{{ statusText }}
diff --git a/apps/desktop-ui/src/components/install/GpuPicker.stories.ts b/apps/desktop-ui/src/components/install/GpuPicker.stories.ts
new file mode 100644
index 000000000..d49893c38
--- /dev/null
+++ b/apps/desktop-ui/src/components/install/GpuPicker.stories.ts
@@ -0,0 +1,84 @@
+// eslint-disable-next-line storybook/no-renderer-packages
+import type { Meta, StoryObj } from '@storybook/vue3'
+import type {
+ ElectronAPI,
+ TorchDeviceType
+} from '@comfyorg/comfyui-electron-types'
+import { ref } from 'vue'
+
+import GpuPicker from './GpuPicker.vue'
+
+type Platform = ReturnType
+type ElectronAPIStub = Pick
+type WindowWithElectron = Window & { electronAPI?: ElectronAPIStub }
+
+const meta: Meta = {
+ title: 'Desktop/Components/GpuPicker',
+ component: GpuPicker,
+ parameters: {
+ layout: 'padded',
+ backgrounds: {
+ default: 'dark',
+ values: [
+ { name: 'dark', value: '#0a0a0a' },
+ { name: 'neutral-900', value: '#171717' },
+ { name: 'neutral-950', value: '#0a0a0a' }
+ ]
+ }
+ }
+}
+
+export default meta
+type Story = StoryObj
+
+function createElectronDecorator(platform: Platform) {
+ function getPlatform() {
+ return platform
+ }
+
+ return function ElectronDecorator() {
+ const windowWithElectron = window as WindowWithElectron
+ windowWithElectron.electronAPI = { getPlatform }
+ return { template: ' ' }
+ }
+}
+
+function renderWithDevice(device: TorchDeviceType | null) {
+ return function Render() {
+ return {
+ components: { GpuPicker },
+ setup() {
+ const selected = ref(device)
+ return { selected }
+ },
+ template: `
+
+
+
+ `
+ }
+ }
+}
+
+const windowsDecorator = createElectronDecorator('win32')
+const macDecorator = createElectronDecorator('darwin')
+
+export const WindowsNvidiaSelected: Story = {
+ decorators: [windowsDecorator],
+ render: renderWithDevice('nvidia')
+}
+
+export const WindowsAmdSelected: Story = {
+ decorators: [windowsDecorator],
+ render: renderWithDevice('amd')
+}
+
+export const WindowsCpuSelected: Story = {
+ decorators: [windowsDecorator],
+ render: renderWithDevice('cpu')
+}
+
+export const MacMpsSelected: Story = {
+ decorators: [macDecorator],
+ render: renderWithDevice('mps')
+}
diff --git a/apps/desktop-ui/src/components/install/GpuPicker.vue b/apps/desktop-ui/src/components/install/GpuPicker.vue
index 98dddb762..217e99ce5 100644
--- a/apps/desktop-ui/src/components/install/GpuPicker.vue
+++ b/apps/desktop-ui/src/components/install/GpuPicker.vue
@@ -11,29 +11,32 @@
-
+
+
+
+
@@ -41,7 +44,6 @@
@@ -81,13 +83,15 @@ const selected = defineModel('device', {
const electron = electronAPI()
const platform = electron.getPlatform()
-const showRecommendedBadge = computed(
- () => selected.value === 'mps' || selected.value === 'nvidia'
+const recommendedDevices: TorchDeviceType[] = ['mps', 'nvidia', 'amd']
+const showRecommendedBadge = computed(() =>
+ selected.value ? recommendedDevices.includes(selected.value) : false
)
const descriptionKeys = {
mps: 'appleMetal',
nvidia: 'nvidia',
+ amd: 'amd',
cpu: 'cpu',
unsupported: 'manual'
} as const
@@ -97,7 +101,7 @@ const descriptionText = computed(() => {
return st(`install.gpuPicker.${key}Description`, '')
})
-const pickGpu = (value: TorchDeviceType) => {
+function pickGpu(value: TorchDeviceType) {
selected.value = value
}
diff --git a/apps/desktop-ui/src/components/install/HardwareOption.stories.ts b/apps/desktop-ui/src/components/install/HardwareOption.stories.ts
index d830af49f..fc0e56713 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 ae254fd8f..9acc9e79c 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 467561b54..fa99e032c 100644
--- a/src/components/MenuHamburger.vue
+++ b/src/components/MenuHamburger.vue
@@ -1,27 +1,27 @@
-
-
diff --git a/src/components/TopMenuSection.test.ts b/src/components/TopMenuSection.test.ts
new file mode 100644
index 000000000..944ce714c
--- /dev/null
+++ b/src/components/TopMenuSection.test.ts
@@ -0,0 +1,164 @@
+import { createTestingPinia } from '@pinia/testing'
+import { mount } from '@vue/test-utils'
+import type { MenuItem } from 'primevue/menuitem'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { computed, nextTick } from 'vue'
+import { createI18n } from 'vue-i18n'
+
+import TopMenuSection from '@/components/TopMenuSection.vue'
+import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
+import LoginButton from '@/components/topbar/LoginButton.vue'
+import type {
+ JobListItem,
+ JobStatus
+} from '@/platform/remote/comfyui/jobs/jobTypes'
+import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
+import { isElectron } from '@/utils/envUtil'
+
+const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
+
+vi.mock('@/composables/auth/useCurrentUser', () => ({
+ useCurrentUser: () => {
+ return {
+ isLoggedIn: computed(() => mockData.isLoggedIn)
+ }
+ }
+}))
+
+vi.mock('@/utils/envUtil')
+vi.mock('@/stores/firebaseAuthStore', () => ({
+ useFirebaseAuthStore: vi.fn(() => ({
+ currentUser: null,
+ loading: false
+ }))
+}))
+
+function createWrapper() {
+ const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {
+ sideToolbar: {
+ queueProgressOverlay: {
+ viewJobHistory: 'View job history',
+ expandCollapsedQueue: 'Expand collapsed queue',
+ activeJobsShort: '{count} active | {count} active',
+ clearQueueTooltip: 'Clear queue'
+ }
+ }
+ }
+ }
+ })
+
+ return mount(TopMenuSection, {
+ global: {
+ plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
+ stubs: {
+ SubgraphBreadcrumb: true,
+ QueueProgressOverlay: true,
+ CurrentUserButton: true,
+ LoginButton: true,
+ ContextMenu: {
+ name: 'ContextMenu',
+ props: ['model'],
+ template: '
'
+ }
+ },
+ directives: {
+ tooltip: () => {}
+ }
+ }
+ })
+}
+
+function createJob(id: string, status: JobStatus): JobListItem {
+ return {
+ id,
+ status,
+ create_time: 0,
+ priority: 0
+ }
+}
+
+function createTask(id: string, status: JobStatus): TaskItemImpl {
+ return new TaskItemImpl(createJob(id, status))
+}
+
+describe('TopMenuSection', () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ })
+
+ describe('authentication state', () => {
+ describe('when user is logged in', () => {
+ beforeEach(() => {
+ mockData.isLoggedIn = true
+ })
+
+ it('should display CurrentUserButton and not display LoginButton', () => {
+ const wrapper = createWrapper()
+ expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
+ expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
+ })
+ })
+
+ describe('when user is not logged in', () => {
+ beforeEach(() => {
+ mockData.isLoggedIn = false
+ })
+
+ describe('on desktop platform', () => {
+ it('should display LoginButton and not display CurrentUserButton', () => {
+ vi.mocked(isElectron).mockReturnValue(true)
+ const wrapper = createWrapper()
+ expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
+ expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
+ })
+ })
+
+ describe('on web platform', () => {
+ it('should not display CurrentUserButton and not display LoginButton', () => {
+ const wrapper = createWrapper()
+ expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
+ expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
+ })
+ })
+ })
+ })
+
+ it('shows the active jobs label with the current count', async () => {
+ const wrapper = createWrapper()
+ const queueStore = useQueueStore()
+ queueStore.pendingTasks = [createTask('pending-1', 'pending')]
+ queueStore.runningTasks = [
+ createTask('running-1', 'in_progress'),
+ createTask('running-2', 'in_progress')
+ ]
+
+ await nextTick()
+
+ const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
+ expect(queueButton.text()).toContain('3 active')
+ })
+
+ it('disables the clear queue context menu item when no queued jobs exist', () => {
+ const wrapper = createWrapper()
+ const menu = wrapper.findComponent({ name: 'ContextMenu' })
+ const model = menu.props('model') as MenuItem[]
+ expect(model[0]?.label).toBe('Clear queue')
+ expect(model[0]?.disabled).toBe(true)
+ })
+
+ it('enables the clear queue context menu item when queued jobs exist', async () => {
+ const wrapper = createWrapper()
+ const queueStore = useQueueStore()
+ queueStore.pendingTasks = [createTask('pending-1', 'pending')]
+
+ await nextTick()
+
+ const menu = wrapper.findComponent({ name: 'ContextMenu' })
+ const model = menu.props('model') as MenuItem[]
+ expect(model[0]?.disabled).toBe(false)
+ })
+})
diff --git a/src/components/TopMenuSection.vue b/src/components/TopMenuSection.vue
index 6eafdc215..b6b7bc4f3 100644
--- a/src/components/TopMenuSection.vue
+++ b/src/components/TopMenuSection.vue
@@ -1,39 +1,179 @@
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ activeJobsLabel }}
+
+
+ {{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }}
+
+
+
+
+
+
+
+
+
+
+
-
+
+const showQueueContextMenu = (event: MouseEvent) => {
+ queueContextMenu.value?.show(event)
+}
+
+const handleClearQueue = async () => {
+ const pendingPromptIds = queueStore.pendingTasks
+ .map((task) => task.promptId)
+ .filter((id): id is string => typeof id === 'string' && id.length > 0)
+
+ await commandStore.execute('Comfy.ClearPendingTasks')
+ executionStore.clearInitializationByPromptIds(pendingPromptIds)
+}
+
+const openCustomNodeManager = async () => {
+ try {
+ await managerState.openManager({
+ initialTab: ManagerTab.All,
+ showToastOnLegacyError: false
+ })
+ } catch (error) {
+ try {
+ toastErrorHandler(error)
+ } catch (toastError) {
+ console.error(error)
+ console.error(toastError)
+ }
+ }
+}
+
diff --git a/src/components/actionbar/ComfyActionbar.vue b/src/components/actionbar/ComfyActionbar.vue
index f01beee25..5ea860852 100644
--- a/src/components/actionbar/ComfyActionbar.vue
+++ b/src/components/actionbar/ComfyActionbar.vue
@@ -1,5 +1,5 @@
-
+
-
@@ -43,21 +54,29 @@ 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')
-const tabContainer = document.querySelector('.workflow-tabs-container')
const panelRef = ref(null)
const dragHandleRef = ref(null)
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
@@ -65,22 +84,10 @@ 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,
- onMove: (event) => {
- // Prevent dragging the menu over the top of the tabs
- const minY = tabContainer?.getBoundingClientRect().bottom ?? 40
- if (event.y < minY) {
- event.y = minY
- }
- }
+ containerElement: document.body
})
// Update storedPosition when x or y changes
@@ -125,13 +132,29 @@ 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)
}
})
+/**
+ * Track run button handle drag start using mousedown on the drag handle.
+ */
+useEventListener(dragHandleRef, 'mousedown', () => {
+ useTelemetry()?.trackUiButtonClicked({
+ button_id: 'actionbar_run_handle_drag_start'
+ })
+})
+
const lastDragState = ref({
x: x.value,
y: y.value,
@@ -245,20 +268,33 @@ 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-[265px] border-dashed border-blue-500 opacity-80',
+ 'w-[200px] border-dashed border-blue-500 opacity-80',
'm-1.5 flex items-center justify-center self-stretch',
'rounded-md before:w-50 before:-ml-50 before:h-full',
+ 'pointer-events-auto',
isMouseOverDropZone.value &&
'border-[3px] opacity-100 scale-105 shadow-[0_0_20px] shadow-blue-500'
)
)
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' : 'fixed'
+ isDocked.value
+ ? '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 a6359763e..4c0ea84e4 100644
--- a/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue
+++ b/src/components/actionbar/ComfyRunButton/ComfyQueueButton.vue
@@ -2,9 +2,7 @@
-
-
-
-
+
+ :variant="item.key === queueMode ? 'primary' : 'secondary'"
+ size="sm"
+ class="w-full justify-start"
+ >
+
+ {{ String(item.label ?? '') }}
+
-
- commandStore.execute('Comfy.Interrupt')"
- />
- {
- if (queueCountStore.count.value > 1) {
- commandStore.execute('Comfy.ClearPendingTasks')
- }
- queueMode = 'disabled'
- }
- "
- />
-
diff --git a/src/components/bottomPanel/BottomPanel.vue b/src/components/bottomPanel/BottomPanel.vue
index f1705d0af..aa6800677 100644
--- a/src/components/bottomPanel/BottomPanel.vue
+++ b/src/components/bottomPanel/BottomPanel.vue
@@ -10,12 +10,12 @@
class="bg-transparent"
>
-
+
+ >
+
+ {{ $t('shortcuts.manageShortcuts') }}
+
+ >
+
+
@@ -79,7 +80,6 @@
-
-
diff --git a/tests-ui/tests/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts b/src/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts
similarity index 97%
rename from tests-ui/tests/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts
rename to src/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts
index faa20f654..b99e54ea1 100644
--- a/tests-ui/tests/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts
+++ b/src/components/bottomPanel/tabs/terminal/BaseTerminal.test.ts
@@ -1,6 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
+import type { Mock } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -151,8 +152,8 @@ describe('BaseTerminal', () => {
// Trigger the selection change callback that was registered during mount
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
// Access the mock calls - TypeScript can't infer the mock structure dynamically
- const selectionCallback = (mockTerminal.onSelectionChange as any).mock
- .calls[0][0]
+ const mockCalls = (mockTerminal.onSelectionChange as Mock).mock.calls
+ const selectionCallback = mockCalls[0][0] as () => void
selectionCallback()
await nextTick()
diff --git a/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue b/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue
index e632d0d47..655df0b65 100644
--- a/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue
+++ b/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue
@@ -11,9 +11,8 @@
value: tooltipText,
showDelay: 300
}"
- icon="pi pi-copy"
- severity="secondary"
- size="small"
+ variant="secondary"
+ size="sm"
:class="
cn('absolute top-2 right-8 transition-opacity', {
'opacity-0 pointer-events-none select-none': !isHovered
@@ -21,18 +20,20 @@
"
:aria-label="tooltipText"
@click="handleCopy"
- />
+ >
+
+
diff --git a/src/components/breadcrumb/SubgraphBreadcrumb.vue b/src/components/breadcrumb/SubgraphBreadcrumb.vue
index c5ee7ab71..bf4ba405a 100644
--- a/src/components/breadcrumb/SubgraphBreadcrumb.vue
+++ b/src/components/breadcrumb/SubgraphBreadcrumb.vue
@@ -1,6 +1,6 @@
+
+
import Breadcrumb from 'primevue/breadcrumb'
+import Button from 'primevue/button'
import type { MenuItem } from 'primevue/menuitem'
import { computed, onUpdated, ref, watch } from 'vue'
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
+import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
+import { useCommandStore } from '@/stores/commandStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { forEachSubgraphNode } from '@/utils/graphTraversalUtil'
@@ -54,6 +77,12 @@ const ICON_WIDTH = 20
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const breadcrumbRef = ref>()
+const rootItemRef = ref>()
+const setItemRef = (item: MenuItem, el: unknown) => {
+ if (item.key === 'root') {
+ rootItemRef.value = el as InstanceType
+ }
+}
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const isBlueprint = computed(() =>
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
@@ -61,18 +90,32 @@ const isBlueprint = computed(() =>
const collapseTabs = ref(false)
const overflowingTabs = ref(false)
-const breadcrumbElement = computed(() => {
- if (!breadcrumbRef.value) return null
+const isInSubgraph = computed(() => navigationStore.navigationStack.length > 0)
- const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
- const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
- return list
-})
+const home = computed(() => ({
+ label: workflowName.value,
+ icon: 'pi pi-home',
+ key: 'root',
+ isBlueprint: isBlueprint.value,
+ command: () => {
+ useTelemetry()?.trackUiButtonClicked({
+ button_id: 'breadcrumb_subgraph_root_selected'
+ })
+ const canvas = useCanvasStore().getCanvas()
+ if (!canvas.graph) throw new TypeError('Canvas has no graph')
+
+ canvas.setGraph(canvas.graph.rootGraph)
+ }
+}))
const items = computed(() => {
const items = navigationStore.navigationStack.map((subgraph) => ({
label: subgraph.name,
+ key: `subgraph-${subgraph.id}`,
command: () => {
+ useTelemetry()?.trackUiButtonClicked({
+ button_id: 'breadcrumb_subgraph_item_selected'
+ })
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -91,18 +134,26 @@ const items = computed(() => {
return [home.value, ...items]
})
-const home = computed(() => ({
- label: workflowName.value,
- icon: 'pi pi-home',
- key: 'root',
- isBlueprint: isBlueprint.value,
- command: () => {
- const canvas = useCanvasStore().getCanvas()
- if (!canvas.graph) throw new TypeError('Canvas has no graph')
+const activeItemKey = computed(() => items.value.at(-1)?.key)
- canvas.setGraph(canvas.graph.rootGraph)
- }
-}))
+const handleMenuClick = (event: MouseEvent) => {
+ useTelemetry()?.trackUiButtonClicked({
+ button_id: 'breadcrumb_subgraph_menu_selected'
+ })
+ rootItemRef.value?.toggleMenu(event)
+}
+
+const handleBackClick = () => {
+ void useCommandStore().execute('Comfy.Graph.ExitSubgraph')
+}
+
+const breadcrumbElement = computed(() => {
+ if (!breadcrumbRef.value) return null
+
+ const el = (breadcrumbRef.value as unknown as { $el: HTMLElement }).$el
+ const list = el?.querySelector('.p-breadcrumb-list') as HTMLElement
+ return list
+})
// Check for overflow on breadcrumb items and collapse/expand the breadcrumb to fit
let overflowObserver: ReturnType | undefined
@@ -182,13 +233,18 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item) {
- @apply flex items-center overflow-hidden;
+ @apply flex items-center overflow-hidden h-8;
min-width: calc(var(--p-breadcrumb-item-min-width) + 1rem);
+ border: 1px solid transparent;
+ background-color: transparent;
+ transition: all 0.2s;
/* Collapse middle items first */
flex-shrink: 10000;
}
:deep(.p-breadcrumb-separator) {
+ border: 1px solid transparent;
+ background-color: transparent;
display: flex;
padding: 0 var(--p-breadcrumb-item-margin);
}
@@ -198,11 +254,9 @@ onUpdated(() => {
calc(var(--p-breadcrumb-item-margin) + var(--p-breadcrumb-item-padding));
}
-:deep(.p-breadcrumb-separator),
-:deep(.p-breadcrumb-item) {
- @apply h-12;
- border-top: 1px solid var(--p-panel-border-color);
- border-bottom: 1px solid var(--p-panel-border-color);
+:deep(.p-breadcrumb-item:hover) {
+ @apply rounded-lg;
+ border-color: var(--interface-stroke);
background-color: var(--comfy-menu-bg);
}
@@ -211,10 +265,8 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item:first-child) {
- @apply rounded-l-lg;
/* Then collapse the root workflow */
flex-shrink: 5000;
- border-left: 1px solid var(--p-panel-border-color);
.p-breadcrumb-item-link {
padding-left: var(--p-breadcrumb-item-padding);
@@ -222,13 +274,10 @@ onUpdated(() => {
}
:deep(.p-breadcrumb-item:last-child) {
- @apply rounded-r-lg;
/* Then collapse the active item */
flex-shrink: 1;
- border-right: 1px solid var(--p-panel-border-color);
}
-:deep(.p-breadcrumb-item-link:hover),
:deep(.p-breadcrumb-item-link-menu-visible) {
background-color: color-mix(
in srgb,
diff --git a/src/components/breadcrumb/SubgraphBreadcrumbItem.vue b/src/components/breadcrumb/SubgraphBreadcrumbItem.vue
index a0c405971..0edcc25f9 100644
--- a/src/components/breadcrumb/SubgraphBreadcrumbItem.vue
+++ b/src/components/breadcrumb/SubgraphBreadcrumbItem.vue
@@ -2,12 +2,12 @@
+
{{ item.label }}
-
+
(), {
isActive: false
})
+const nodeDefStore = useNodeDefStore()
+const hasMissingNodes = computed(() =>
+ graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
+)
+
const { t } = useI18n()
const menu = ref & MenuState>()
const dialogService = useDialogService()
@@ -115,80 +128,37 @@ const rename = async (
}
const isRoot = props.item.key === 'root'
-const menuItems = computed(() => {
- return [
- {
- label: t('g.rename'),
- icon: 'pi pi-pencil',
- command: startRename
- },
- {
- label: t('breadcrumbsMenu.duplicate'),
- icon: 'pi pi-copy',
- command: async () => {
- await workflowService.duplicateWorkflow(workflowStore.activeWorkflow!)
- },
- visible: isRoot && !props.item.isBlueprint
- },
- {
- separator: true,
- visible: isRoot
- },
- {
- label: t('menuLabels.Save'),
- icon: 'pi pi-save',
- command: async () => {
- await useCommandStore().execute('Comfy.SaveWorkflow')
- },
- visible: isRoot
- },
- {
- label: t('menuLabels.Save As'),
- icon: 'pi pi-save',
- command: async () => {
- await useCommandStore().execute('Comfy.SaveWorkflowAs')
- },
- visible: isRoot
- },
- {
- separator: true
- },
- {
- label: t('breadcrumbsMenu.clearWorkflow'),
- icon: 'pi pi-trash',
- command: async () => {
- await useCommandStore().execute('Comfy.ClearWorkflow')
- }
- },
- {
- separator: true,
- visible: props.item.key === 'root' && props.item.isBlueprint
- },
- {
- label: t('subgraphStore.publish'),
- icon: 'pi pi-copy',
- command: async () => {
- await workflowService.saveWorkflowAs(workflowStore.activeWorkflow!)
- },
- visible: props.item.key === 'root' && props.item.isBlueprint
- },
- {
- separator: true,
- visible: isRoot
- },
- {
- label: props.item.isBlueprint
- ? t('breadcrumbsMenu.deleteBlueprint')
- : t('breadcrumbsMenu.deleteWorkflow'),
- icon: 'pi pi-times',
- command: async () => {
- await workflowService.deleteWorkflow(workflowStore.activeWorkflow!)
- },
- visible: isRoot
- }
- ]
+
+const tooltipText = computed(() => {
+ if (hasMissingNodes.value && isRoot) {
+ return t('breadcrumbsMenu.missingNodesWarning')
+ }
+ return props.item.label
})
+const startRename = async () => {
+ // Check if element is hidden (collapsed breadcrumb)
+ // When collapsed, root item is hidden via CSS display:none, so use rename command
+ if (isRoot && wrapperRef.value?.offsetParent === null) {
+ await useCommandStore().execute('Comfy.RenameWorkflow')
+ return
+ }
+
+ isEditing.value = true
+ itemLabel.value = props.item.label as string
+ void nextTick(() => {
+ if (itemInputRef.value?.$el) {
+ itemInputRef.value.$el.focus()
+ itemInputRef.value.$el.select()
+ if (wrapperRef.value) {
+ itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
+ }
+ }
+ })
+}
+
+const { menuItems } = useWorkflowActionsMenu(startRename, { isRoot })
+
const handleClick = (event: MouseEvent) => {
if (isEditing.value) {
return
@@ -208,20 +178,6 @@ const handleClick = (event: MouseEvent) => {
}
}
-const startRename = () => {
- isEditing.value = true
- itemLabel.value = props.item.label as string
- void nextTick(() => {
- if (itemInputRef.value?.$el) {
- itemInputRef.value.$el.focus()
- itemInputRef.value.$el.select()
- if (wrapperRef.value) {
- itemInputRef.value.$el.style.width = `${Math.max(200, wrapperRef.value.offsetWidth)}px`
- }
- }
- })
-}
-
const inputBlur = async (doRename: boolean) => {
if (doRename) {
await rename(itemLabel.value, props.item.label as string)
@@ -229,6 +185,14 @@ const inputBlur = async (doRename: boolean) => {
isEditing.value = false
}
+
+const toggleMenu = (event: MouseEvent) => {
+ menu.value?.toggle(event)
+}
+
+defineExpose({
+ toggleMenu
+})
diff --git a/src/components/common/StatusBadge.stories.ts b/src/components/common/StatusBadge.stories.ts
new file mode 100644
index 000000000..39ef6253c
--- /dev/null
+++ b/src/components/common/StatusBadge.stories.ts
@@ -0,0 +1,95 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+
+import StatusBadge from './StatusBadge.vue'
+
+const meta = {
+ title: 'Common/StatusBadge',
+ component: StatusBadge,
+ tags: ['autodocs'],
+ argTypes: {
+ label: { control: 'text' },
+ severity: {
+ control: 'select',
+ options: ['default', 'secondary', 'warn', 'danger', 'contrast']
+ },
+ variant: {
+ control: 'select',
+ options: ['label', 'dot', 'circle']
+ }
+ },
+ args: {
+ label: 'Status',
+ severity: 'default'
+ }
+} satisfies Meta
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {}
+
+export const Failed: Story = {
+ args: {
+ label: 'Failed',
+ severity: 'danger'
+ }
+}
+
+export const Finished: Story = {
+ args: {
+ label: 'Finished',
+ severity: 'contrast'
+ }
+}
+
+export const Dot: Story = {
+ args: {
+ label: undefined,
+ variant: 'dot',
+ severity: 'danger'
+ }
+}
+
+export const Circle: Story = {
+ args: {
+ label: '3',
+ variant: 'circle'
+ }
+}
+
+export const AllSeverities: Story = {
+ render: () => ({
+ components: { StatusBadge },
+ template: `
+
+
+
+
+
+
+
+ `
+ })
+}
+
+export const AllVariants: Story = {
+ render: () => ({
+ components: { StatusBadge },
+ template: `
+
+
+
+ label
+
+
+
+ dot
+
+
+
+ circle
+
+
+ `
+ })
+}
diff --git a/src/components/common/StatusBadge.vue b/src/components/common/StatusBadge.vue
new file mode 100644
index 000000000..a29cd966d
--- /dev/null
+++ b/src/components/common/StatusBadge.vue
@@ -0,0 +1,27 @@
+
+
+
+
+ {{ label }}
+
+
diff --git a/src/components/common/SystemStatsPanel.vue b/src/components/common/SystemStatsPanel.vue
index 1d3612aa2..5d3cd8965 100644
--- a/src/components/common/SystemStatsPanel.vue
+++ b/src/components/common/SystemStatsPanel.vue
@@ -9,29 +9,31 @@
{{ col.header }}
- {{ formatValue(systemInfo[col.field], col.field) }}
+ {{ getDisplayValue(col) }}
-
+
+
-
-
- {{ $t('g.devices') }}
-
-
-
-
-
-
-
-
+
+
+ {{ $t('g.devices') }}
+
+
+
+
+
+
+
+
+
@@ -42,8 +44,9 @@ import TabView from 'primevue/tabview'
import { computed } from 'vue'
import DeviceInfo from '@/components/common/DeviceInfo.vue'
+import { isCloud } from '@/platform/distribution/types'
import type { SystemStats } from '@/schemas/apiSchema'
-import { formatSize } from '@/utils/formatUtil'
+import { formatCommitHash, formatSize } from '@/utils/formatUtil'
const props = defineProps<{
stats: SystemStats
@@ -54,20 +57,53 @@ const systemInfo = computed(() => ({
argv: props.stats.system.argv.join(' ')
}))
-const systemColumns: { field: keyof SystemStats['system']; header: string }[] =
- [
- { field: 'os', header: 'OS' },
- { field: 'python_version', header: 'Python Version' },
- { field: 'embedded_python', header: 'Embedded Python' },
- { field: 'pytorch_version', header: 'Pytorch Version' },
- { field: 'argv', header: 'Arguments' },
- { field: 'ram_total', header: 'RAM Total' },
- { field: 'ram_free', header: 'RAM Free' }
- ]
+const hasDevices = computed(() => props.stats.devices.length > 0)
-const formatValue = (value: any, field: string) => {
- if (['ram_total', 'ram_free'].includes(field)) {
- return formatSize(value)
+type SystemInfoKey = keyof SystemStats['system']
+
+type ColumnDef = {
+ field: SystemInfoKey
+ header: string
+ format?: (value: string) => string
+ formatNumber?: (value: number) => string
+}
+
+/** Columns for local distribution */
+const localColumns: ColumnDef[] = [
+ { field: 'os', header: 'OS' },
+ { field: 'python_version', header: 'Python Version' },
+ { field: 'embedded_python', header: 'Embedded Python' },
+ { field: 'pytorch_version', header: 'Pytorch Version' },
+ { field: 'argv', header: 'Arguments' },
+ { field: 'ram_total', header: 'RAM Total', formatNumber: formatSize },
+ { field: 'ram_free', header: 'RAM Free', formatNumber: formatSize }
+]
+
+/** Columns for cloud distribution */
+const cloudColumns: ColumnDef[] = [
+ { field: 'cloud_version', header: 'Cloud Version' },
+ {
+ field: 'comfyui_version',
+ header: 'ComfyUI Version',
+ format: formatCommitHash
+ },
+ {
+ field: 'comfyui_frontend_version',
+ header: 'Frontend Version',
+ format: formatCommitHash
+ },
+ { field: 'workflow_templates_version', header: 'Templates Version' }
+]
+
+const systemColumns = computed(() => (isCloud ? cloudColumns : localColumns))
+
+const getDisplayValue = (column: ColumnDef) => {
+ const value = systemInfo.value[column.field]
+ if (column.formatNumber && typeof value === 'number') {
+ return column.formatNumber(value)
+ }
+ if (column.format && typeof value === 'string') {
+ return column.format(value)
}
return value
}
diff --git a/src/components/common/TreeExplorer.vue b/src/components/common/TreeExplorer.vue
index c6a3dbe60..828d8ff45 100644
--- a/src/components/common/TreeExplorer.vue
+++ b/src/components/common/TreeExplorer.vue
@@ -2,7 +2,7 @@
diff --git a/src/components/common/UrlInput.test.ts b/src/components/common/UrlInput.test.ts
index e3fc81d29..9c34c11c5 100644
--- a/src/components/common/UrlInput.test.ts
+++ b/src/components/common/UrlInput.test.ts
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { createApp, nextTick } from 'vue'
import UrlInput from './UrlInput.vue'
+import type { ComponentProps } from 'vue-component-type-helpers'
describe('UrlInput', () => {
beforeEach(() => {
@@ -14,7 +15,13 @@ describe('UrlInput', () => {
app.use(PrimeVue)
})
- const mountComponent = (props: any, options = {}) => {
+ const mountComponent = (
+ props: ComponentProps & {
+ placeholder?: string
+ disabled?: boolean
+ },
+ options = {}
+ ) => {
return mount(UrlInput, {
global: {
plugins: [PrimeVue],
@@ -169,25 +176,25 @@ describe('UrlInput', () => {
await input.setValue(' https://leading-space.com')
await input.trigger('input')
await nextTick()
- expect(wrapper.vm.internalValue).toBe('https://leading-space.com')
+ expect(input.element.value).toBe('https://leading-space.com')
// Test trailing whitespace
await input.setValue('https://trailing-space.com ')
await input.trigger('input')
await nextTick()
- expect(wrapper.vm.internalValue).toBe('https://trailing-space.com')
+ expect(input.element.value).toBe('https://trailing-space.com')
// Test both leading and trailing whitespace
await input.setValue(' https://both-spaces.com ')
await input.trigger('input')
await nextTick()
- expect(wrapper.vm.internalValue).toBe('https://both-spaces.com')
+ expect(input.element.value).toBe('https://both-spaces.com')
// Test whitespace in the middle of the URL
await input.setValue('https:// middle-space.com')
await input.trigger('input')
await nextTick()
- expect(wrapper.vm.internalValue).toBe('https://middle-space.com')
+ expect(input.element.value).toBe('https://middle-space.com')
})
it('trims whitespace when value set externally', async () => {
@@ -196,15 +203,17 @@ describe('UrlInput', () => {
placeholder: 'Enter URL'
})
+ const input = wrapper.find('input')
+
// Check initial value is trimmed
- expect(wrapper.vm.internalValue).toBe('https://initial-value.com')
+ expect(input.element.value).toBe('https://initial-value.com')
// Update props with whitespace
await wrapper.setProps({ modelValue: ' https://updated-value.com ' })
await nextTick()
// Check updated value is trimmed
- expect(wrapper.vm.internalValue).toBe('https://updated-value.com')
+ expect(input.element.value).toBe('https://updated-value.com')
})
})
})
diff --git a/src/components/common/UserAvatar.test.ts b/src/components/common/UserAvatar.test.ts
index d844277b9..0b5df7052 100644
--- a/src/components/common/UserAvatar.test.ts
+++ b/src/components/common/UserAvatar.test.ts
@@ -1,3 +1,5 @@
+import type { ComponentProps } from 'vue-component-type-helpers'
+
import { mount } from '@vue/test-utils'
import Avatar from 'primevue/avatar'
import PrimeVue from 'primevue/config'
@@ -27,7 +29,7 @@ describe('UserAvatar', () => {
app.use(PrimeVue)
})
- const mountComponent = (props: any = {}) => {
+ const mountComponent = (props: ComponentProps = {}) => {
return mount(UserAvatar, {
global: {
plugins: [PrimeVue, i18n],
@@ -56,7 +58,7 @@ describe('UserAvatar', () => {
const avatar = wrapper.findComponent(Avatar)
expect(avatar.exists()).toBe(true)
expect(avatar.props('image')).toBeNull()
- expect(avatar.props('icon')).toBe('pi pi-user')
+ expect(avatar.props('icon')).toBe('icon-[lucide--user]')
})
it('renders with default icon when provided photo Url is null', () => {
@@ -67,7 +69,7 @@ describe('UserAvatar', () => {
const avatar = wrapper.findComponent(Avatar)
expect(avatar.exists()).toBe(true)
expect(avatar.props('image')).toBeNull()
- expect(avatar.props('icon')).toBe('pi pi-user')
+ expect(avatar.props('icon')).toBe('icon-[lucide--user]')
})
it('falls back to icon when image fails to load', async () => {
@@ -82,7 +84,7 @@ describe('UserAvatar', () => {
avatar.vm.$emit('error')
await nextTick()
- expect(avatar.props('icon')).toBe('pi pi-user')
+ expect(avatar.props('icon')).toBe('icon-[lucide--user]')
})
it('uses provided ariaLabel', () => {
diff --git a/src/components/common/UserAvatar.vue b/src/components/common/UserAvatar.vue
index 8fce43d10..1eec243d6 100644
--- a/src/components/common/UserAvatar.vue
+++ b/src/components/common/UserAvatar.vue
@@ -1,7 +1,9 @@
({
+ 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 aac405b90..987406ede 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 89de6e421..fb6b4374c 100644
--- a/src/components/common/VirtualGrid.vue
+++ b/src/components/common/VirtualGrid.vue
@@ -1,16 +1,20 @@
-
-
-
-
+
@@ -28,19 +32,22 @@ type GridState = {
const {
items,
+ gridStyle,
bufferRows = 1,
scrollThrottle = 64,
resizeDebounce = 64,
defaultItemHeight = 200,
- defaultItemWidth = 200
+ defaultItemWidth = 200,
+ maxColumns = Infinity
} = defineProps<{
items: (T & { key: string })[]
- gridStyle: Partial
+ gridStyle: CSSProperties
bufferRows?: number
scrollThrottle?: number
resizeDebounce?: number
defaultItemHeight?: number
defaultItemWidth?: number
+ maxColumns?: number
}>()
const emit = defineEmits<{
@@ -59,7 +66,18 @@ const { y: scrollY } = useScroll(container, {
eventListenerOptions: { passive: true }
})
-const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1)
+const cols = computed(() =>
+ Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
+)
+
+const mergedGridStyle = computed(() => {
+ if (maxColumns === Infinity) return gridStyle
+ return {
+ ...gridStyle,
+ gridTemplateColumns: `repeat(${maxColumns}, minmax(0, 1fr))`
+ }
+})
+
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
const isValidGrid = computed(() => height.value && width.value && items?.length)
@@ -83,6 +101,16 @@ const renderedItems = computed(() =>
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
)
+function rowsToHeight(rows: number): string {
+ return `${(rows / cols.value) * itemHeight.value}px`
+}
+const topSpacerStyle = computed(() => ({
+ height: rowsToHeight(state.value.start)
+}))
+const bottomSpacerStyle = computed(() => ({
+ height: rowsToHeight(items.length - state.value.end)
+}))
+
whenever(
() => state.value.isNearEnd,
() => {
@@ -109,24 +137,6 @@ const onResize = debounce(updateItemSize, resizeDebounce)
watch([width, height], onResize, { flush: 'post' })
whenever(() => items, updateItemSize, { flush: 'post' })
onBeforeUnmount(() => {
- onResize.cancel() // Clear pending debounced calls
+ onResize.cancel()
})
-
-
diff --git a/src/components/common/WorkspaceProfilePic.vue b/src/components/common/WorkspaceProfilePic.vue
new file mode 100644
index 000000000..642317267
--- /dev/null
+++ b/src/components/common/WorkspaceProfilePic.vue
@@ -0,0 +1,43 @@
+
+
+ {{ letter }}
+
+
+
+
diff --git a/src/components/common/statusBadge.variants.ts b/src/components/common/statusBadge.variants.ts
new file mode 100644
index 000000000..479a0dda8
--- /dev/null
+++ b/src/components/common/statusBadge.variants.ts
@@ -0,0 +1,26 @@
+import type { VariantProps } from 'cva'
+import { cva } from 'cva'
+
+export const statusBadgeVariants = cva({
+ base: 'inline-flex items-center justify-center rounded-full',
+ variants: {
+ severity: {
+ default: 'bg-primary-background text-base-foreground',
+ secondary: 'bg-secondary-background text-base-foreground',
+ warn: 'bg-warning-background text-base-background',
+ danger: 'bg-destructive-background text-white',
+ contrast: 'bg-base-foreground text-base-background'
+ },
+ variant: {
+ label: 'h-3.5 px-1 text-xxxs font-semibold uppercase',
+ dot: 'size-2',
+ circle: 'size-3.5 text-xxxs font-semibold'
+ }
+ },
+ defaultVariants: {
+ severity: 'default',
+ variant: 'label'
+ }
+})
+
+export type StatusBadgeVariants = VariantProps
diff --git a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
index c64a8e19d..aeb98971b 100644
--- a/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
+++ b/src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
@@ -22,67 +22,70 @@
-
-
-
-
-
+
+ {{
+ $t('templateWorkflows.resetFilters', 'Clear Filters')
+ }}
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
-
+
-
+
@@ -144,7 +147,7 @@
size="compact"
variant="ghost"
rounded="lg"
- class="hover:bg-white dark-theme:hover:bg-zinc-800"
+ class="hover:bg-base-background"
>
@@ -172,13 +175,14 @@
-
-
+
@@ -323,7 +327,7 @@
size="compact"
variant="ghost"
rounded="lg"
- class="hover:bg-white dark-theme:hover:bg-zinc-800"
+ class="hover:bg-base-background"
>
@@ -362,10 +366,7 @@
-
+
{{
$t('templateWorkflows.resultsCount', {
count: filteredCount,
@@ -380,40 +381,91 @@
@@ -55,6 +69,16 @@ const dialogStore = useDialogStore()
@apply pt-0;
}
+/* Workspace mode: wider settings dialog */
+.settings-dialog-workspace {
+ width: 100%;
+ max-width: 1440px;
+}
+
+.settings-dialog-workspace .p-dialog-content {
+ width: 100%;
+}
+
.manager-dialog {
height: 80vh;
max-width: 1724px;
diff --git a/src/components/dialog/confirm/ConfirmBody.vue b/src/components/dialog/confirm/ConfirmBody.vue
new file mode 100644
index 000000000..9a1cd5980
--- /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 000000000..9cdd6e37b
--- /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 000000000..3c8337733
--- /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 000000000..c615e6475
--- /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 0e12f8a61..41ad903c7 100644
--- a/src/components/dialog/content/ApiNodesSignInContent.vue
+++ b/src/components/dialog/content/ApiNodesSignInContent.vue
@@ -11,25 +11,29 @@
-
+
+ {{ t('g.learnMore') }}
+
-
-
+
+ {{ t('g.cancel') }}
+
+
+ {{ t('g.login') }}
+
diff --git a/src/components/dialog/content/ConfirmationDialogContent.vue b/src/components/dialog/content/ConfirmationDialogContent.vue
index ee21d76ce..5dbb6cd9e 100644
--- a/src/components/dialog/content/ConfirmationDialogContent.vue
+++ b/src/components/dialog/content/ConfirmationDialogContent.vue
@@ -31,69 +31,64 @@
}}
-
-
+
+
+ {{ $t('g.cancel') }}
+
+
+
+ {{ $t('g.confirm') }}
+
+ >
+
+ {{ $t('g.delete') }}
+
+ >
+
+ {{ $t('g.overwrite') }}
+
-
-
+
+
+ {{ $t('g.no') }}
+
+
+
+ {{ $t('g.save') }}
+
+ >
+
+ {{ $t('desktopMenu.reinstall') }}
+
-
+
+
+ {{ $t('g.close') }}
+
diff --git a/src/components/dialog/content/LoadWorkflowWarning.vue b/src/components/dialog/content/MissingNodesFooter.vue
similarity index 50%
rename from src/components/dialog/content/LoadWorkflowWarning.vue
rename to src/components/dialog/content/MissingNodesFooter.vue
index 85a3e81ac..ba203660e 100644
--- a/src/components/dialog/content/LoadWorkflowWarning.vue
+++ b/src/components/dialog/content/MissingNodesFooter.vue
@@ -1,39 +1,33 @@
-
-
-
+
-
-
- {{ slotProps.option.label }}
- {{
- slotProps.option.hint
- }}
-
-
-
-
-
+
+
+ {{ $t('missingNodes.cloud.learnMore') }}
+
+ {{
+ $t('missingNodes.cloud.gotIt')
+ }}
+
+
+
+
+
{{
+ $t('g.openManager')
+ }}
-
-
-
diff --git a/src/components/dialog/content/MissingNodesHeader.vue b/src/components/dialog/content/MissingNodesHeader.vue
new file mode 100644
index 000000000..1e150c3d3
--- /dev/null
+++ b/src/components/dialog/content/MissingNodesHeader.vue
@@ -0,0 +1,18 @@
+
+
+
+
+
+ {{
+ isCloud
+ ? $t('missingNodes.cloud.title')
+ : $t('missingNodes.oss.title')
+ }}
+
+
+
+
+
+
diff --git a/src/components/dialog/content/PromptDialogContent.vue b/src/components/dialog/content/PromptDialogContent.vue
index 14e70e861..92c721a24 100644
--- a/src/components/dialog/content/PromptDialogContent.vue
+++ b/src/components/dialog/content/PromptDialogContent.vue
@@ -4,6 +4,7 @@
diff --git a/src/components/dialog/content/UpdatePasswordContent.vue b/src/components/dialog/content/UpdatePasswordContent.vue
index dc116e9c2..ef99a7788 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 000000000..7faf432e7
--- /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 402df3ac0..c67c273a2 100644
--- a/src/components/dialog/content/credit/CreditTopUpOption.vue
+++ b/src/components/dialog/content/credit/CreditTopUpOption.vue
@@ -1,76 +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 e4c32b471..767202251 100644
--- a/src/components/dialog/content/error/FindIssueButton.vue
+++ b/src/components/dialog/content/error/FindIssueButton.vue
@@ -1,16 +1,16 @@
-
+
+
+ {{ $t('g.findIssues') }}
+
diff --git a/src/components/dialog/content/setting/WorkspacePanelContent.vue b/src/components/dialog/content/setting/WorkspacePanelContent.vue
new file mode 100644
index 000000000..9366a573f
--- /dev/null
+++ b/src/components/dialog/content/setting/WorkspacePanelContent.vue
@@ -0,0 +1,163 @@
+
+
+
+
+
+ {{ workspaceName }}
+
+
+
+
+
+ {{ $t('workspacePanel.tabs.planCredits') }}
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/dialog/content/setting/WorkspaceSidebarItem.vue b/src/components/dialog/content/setting/WorkspaceSidebarItem.vue
new file mode 100644
index 000000000..cab92c7a8
--- /dev/null
+++ b/src/components/dialog/content/setting/WorkspaceSidebarItem.vue
@@ -0,0 +1,19 @@
+
+
+
+
+ {{ workspaceName }}
+
+
+
+
diff --git a/src/components/dialog/content/signin/ApiKeyForm.test.ts b/src/components/dialog/content/signin/ApiKeyForm.test.ts
index bf1ec2cdd..4d073cb08 100644
--- a/src/components/dialog/content/signin/ApiKeyForm.test.ts
+++ b/src/components/dialog/content/signin/ApiKeyForm.test.ts
@@ -1,7 +1,9 @@
+import type { ComponentProps } from 'vue-component-type-helpers'
+
import { Form } from '@primevue/forms'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
-import Button from 'primevue/button'
+import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
@@ -9,7 +11,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
-import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
+import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import ApiKeyForm from './ApiKeyForm.vue'
@@ -63,7 +65,7 @@ describe('ApiKeyForm', () => {
mockLoading.mockReset()
})
- const mountComponent = (props: any = {}) => {
+ const mountComponent = (props: ComponentProps = {}) => {
return mount(ApiKeyForm, {
global: {
plugins: [PrimeVue, createPinia(), i18n],
@@ -99,9 +101,10 @@ describe('ApiKeyForm', () => {
)
await wrapper.find('form').trigger('submit')
- const submitButton = wrapper
- .findAllComponents(Button)
- .find((btn) => btn.text() === 'Save')
+ const buttons = wrapper.findAllComponents(Button)
+ const submitButton = buttons.find(
+ (btn) => btn.attributes('type') === 'submit'
+ )
expect(submitButton?.props('loading')).toBe(true)
})
@@ -111,7 +114,7 @@ describe('ApiKeyForm', () => {
const helpText = wrapper.find('small')
expect(helpText.text()).toContain('Need an API key?')
expect(helpText.find('a').attributes('href')).toBe(
- `${COMFY_PLATFORM_BASE_URL}/login`
+ `${getComfyPlatformBaseUrl()}/login`
)
})
})
diff --git a/src/components/dialog/content/signin/ApiKeyForm.vue b/src/components/dialog/content/signin/ApiKeyForm.vue
index 2e70345fc..22ecb3205 100644
--- a/src/components/dialog/content/signin/ApiKeyForm.vue
+++ b/src/components/dialog/content/signin/ApiKeyForm.vue
@@ -48,7 +48,7 @@
{{ t('auth.apiKey.helpText') }}
@@ -67,10 +67,15 @@
-
+
{{ t('g.back') }}
-
+
{{ t('g.save') }}
@@ -82,13 +87,17 @@
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
-import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
-import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
+import Button from '@/components/ui/button/Button.vue'
+import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
+import {
+ configValueOrDefault,
+ remoteConfig
+} from '@/platform/remoteConfig/remoteConfig'
import { apiKeySchema } from '@/schemas/signInSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -96,6 +105,13 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const apiKeyStore = useApiKeyAuthStore()
const loading = computed(() => authStore.loading)
+const comfyPlatformBaseUrl = computed(() =>
+ configValueOrDefault(
+ remoteConfig.value,
+ 'comfy_platform_base_url',
+ getComfyPlatformBaseUrl()
+ )
+)
const { t } = useI18n()
diff --git a/src/components/dialog/content/signin/SignInForm.test.ts b/src/components/dialog/content/signin/SignInForm.test.ts
index d8f3721c1..c27d15929 100644
--- a/src/components/dialog/content/signin/SignInForm.test.ts
+++ b/src/components/dialog/content/signin/SignInForm.test.ts
@@ -1,7 +1,7 @@
import { Form } from '@primevue/forms'
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 PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
@@ -112,8 +112,10 @@ describe('SignInForm', () => {
// Mock getElementById to track focus
const mockFocus = vi.fn()
- const mockElement = { focus: mockFocus }
- vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
+ const mockElement: Partial = { focus: mockFocus }
+ vi.spyOn(document, 'getElementById').mockReturnValue(
+ mockElement as HTMLElement
+ )
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
@@ -138,7 +140,10 @@ describe('SignInForm', () => {
it('calls handleForgotPassword with email when link is clicked', async () => {
const wrapper = mountComponent()
- const component = wrapper.vm as any
+ const component = wrapper.vm as typeof wrapper.vm & {
+ handleForgotPassword: (email: string, valid: boolean) => void
+ onSubmit: (data: { valid: boolean; values: unknown }) => void
+ }
// Spy on handleForgotPassword
const handleForgotPasswordSpy = vi.spyOn(
@@ -161,7 +166,10 @@ describe('SignInForm', () => {
describe('Form Submission', () => {
it('emits submit event when onSubmit is called with valid data', async () => {
const wrapper = mountComponent()
- const component = wrapper.vm as any
+ const component = wrapper.vm as typeof wrapper.vm & {
+ handleForgotPassword: (email: string, valid: boolean) => void
+ onSubmit: (data: { valid: boolean; values: unknown }) => void
+ }
// Call onSubmit directly with valid data
component.onSubmit({
@@ -181,7 +189,10 @@ describe('SignInForm', () => {
it('does not emit submit event when form is invalid', async () => {
const wrapper = mountComponent()
- const component = wrapper.vm as any
+ const component = wrapper.vm as typeof wrapper.vm & {
+ handleForgotPassword: (email: string, valid: boolean) => void
+ onSubmit: (data: { valid: boolean; values: unknown }) => void
+ }
// Call onSubmit with invalid form
component.onSubmit({ valid: false, values: {} })
@@ -254,12 +265,17 @@ describe('SignInForm', () => {
describe('Focus Behavior', () => {
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
const wrapper = mountComponent()
- const component = wrapper.vm as any
+ const component = wrapper.vm as typeof wrapper.vm & {
+ handleForgotPassword: (email: string, valid: boolean) => void
+ onSubmit: (data: { valid: boolean; values: unknown }) => void
+ }
// Mock getElementById to track focus
const mockFocus = vi.fn()
- const mockElement = { focus: mockFocus }
- vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
+ const mockElement: Partial = { focus: mockFocus }
+ vi.spyOn(document, 'getElementById').mockReturnValue(
+ mockElement as HTMLElement
+ )
// Call handleForgotPassword with no email
await component.handleForgotPassword('', false)
@@ -273,12 +289,17 @@ describe('SignInForm', () => {
it('does not focus email input when valid email is provided', async () => {
const wrapper = mountComponent()
- const component = wrapper.vm as any
+ const component = wrapper.vm as typeof wrapper.vm & {
+ handleForgotPassword: (email: string, valid: boolean) => void
+ onSubmit: (data: { valid: boolean; values: unknown }) => void
+ }
// Mock getElementById
const mockFocus = vi.fn()
- const mockElement = { focus: mockFocus }
- vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
+ const mockElement: Partial = { focus: mockFocus }
+ vi.spyOn(document, 'getElementById').mockReturnValue(
+ mockElement as HTMLElement
+ )
// Call handleForgotPassword with valid email
await component.handleForgotPassword('test@example.com', true)
diff --git a/src/components/dialog/content/signin/SignInForm.vue b/src/components/dialog/content/signin/SignInForm.vue
index 1560911e0..cbc946fea 100644
--- a/src/components/dialog/content/signin/SignInForm.vue
+++ b/src/components/dialog/content/signin/SignInForm.vue
@@ -60,13 +60,15 @@
-
+
+ :disabled="!$form.valid"
+ >
+ {{ t('auth.login.loginButton') }}
+
@@ -74,7 +76,7 @@
import type { FormSubmitEvent } from '@primevue/forms'
import { Form } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
-import Button from 'primevue/button'
+import { useThrottleFn } from '@vueuse/core'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
@@ -82,6 +84,7 @@ import { useToast } from 'primevue/usetoast'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
+import Button from '@/components/ui/button/Button.vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { signInSchema } from '@/schemas/signInSchema'
import type { SignInData } from '@/schemas/signInSchema'
@@ -100,11 +103,11 @@ const emit = defineEmits<{
const emailInputId = 'comfy-org-sign-in-email'
-const onSubmit = (event: FormSubmitEvent) => {
+const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
}
-}
+}, 1_500)
const handleForgotPassword = async (
email: string,
diff --git a/src/components/dialog/content/signin/SignUpForm.vue b/src/components/dialog/content/signin/SignUpForm.vue
index 90a526363..58b44578f 100644
--- a/src/components/dialog/content/signin/SignUpForm.vue
+++ b/src/components/dialog/content/signin/SignUpForm.vue
@@ -1,5 +1,6 @@
+
+ :disabled="!$form.valid"
+ >
+ {{ t('auth.signup.signUpButton') }}
+
@@ -40,24 +45,30 @@
import type { FormSubmitEvent } from '@primevue/forms'
import { Form, FormField } from '@primevue/forms'
import { zodResolver } from '@primevue/forms/resolvers/zod'
-import Button from 'primevue/button'
+import { useThrottleFn } from '@vueuse/core'
import InputText from 'primevue/inputtext'
+import ProgressSpinner from 'primevue/progressspinner'
+import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
+import Button from '@/components/ui/button/Button.vue'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
+import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import PasswordFields from './PasswordFields.vue'
const { t } = useI18n()
+const authStore = useFirebaseAuthStore()
+const loading = computed(() => authStore.loading)
const emit = defineEmits<{
submit: [values: SignUpData]
}>()
-const onSubmit = (event: FormSubmitEvent) => {
+const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignUpData)
}
-}
+}, 1_500)
diff --git a/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue
new file mode 100644
index 000000000..b9444ce58
--- /dev/null
+++ b/src/components/dialog/content/workspace/CreateWorkspaceDialogContent.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+ {{ $t('workspacePanel.createWorkspaceDialog.title') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('workspacePanel.createWorkspaceDialog.message') }}
+
+
+
+ {{ $t('workspacePanel.createWorkspaceDialog.nameLabel') }}
+
+
+
+
+
+
+
+
+ {{ $t('g.cancel') }}
+
+
+ {{ $t('workspacePanel.createWorkspaceDialog.create') }}
+
+
+
+
+
+
diff --git a/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue
new file mode 100644
index 000000000..dea2da18d
--- /dev/null
+++ b/src/components/dialog/content/workspace/DeleteWorkspaceDialogContent.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
+ {{ $t('workspacePanel.deleteDialog.title') }}
+
+
+
+
+
+
+
+
+
+ {{
+ workspaceName
+ ? $t('workspacePanel.deleteDialog.messageWithName', {
+ name: workspaceName
+ })
+ : $t('workspacePanel.deleteDialog.message')
+ }}
+
+
+
+
+
+
+ {{ $t('g.cancel') }}
+
+
+ {{ $t('g.delete') }}
+
+
+
+
+
+
diff --git a/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue
new file mode 100644
index 000000000..62b650a4e
--- /dev/null
+++ b/src/components/dialog/content/workspace/EditWorkspaceDialogContent.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+ {{ $t('workspacePanel.editWorkspaceDialog.title') }}
+
+
+
+
+
+
+
+
+
+
+ {{ $t('workspacePanel.editWorkspaceDialog.nameLabel') }}
+
+
+
+
+
+
+
+
+ {{ $t('g.cancel') }}
+
+
+ {{ $t('workspacePanel.editWorkspaceDialog.save') }}
+
+
+
+
+
+
diff --git a/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue b/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue
new file mode 100644
index 000000000..6a3d16c36
--- /dev/null
+++ b/src/components/dialog/content/workspace/LeaveWorkspaceDialogContent.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
+ {{ $t('workspacePanel.leaveDialog.title') }}
+
+
+
+
+
+
+
+
+
+ {{ $t('workspacePanel.leaveDialog.message') }}
+
+
+
+
+
+
+ {{ $t('g.cancel') }}
+
+
+ {{ $t('workspacePanel.leaveDialog.leave') }}
+
+
+
+
+
+
diff --git a/src/components/dialog/header/SettingDialogHeader.vue b/src/components/dialog/header/SettingDialogHeader.vue
index 66765846f..959cfa14d 100644
--- a/src/components/dialog/header/SettingDialogHeader.vue
+++ b/src/components/dialog/header/SettingDialogHeader.vue
@@ -15,9 +15,7 @@
diff --git a/src/components/graph/selectionToolbox/BypassButton.test.ts b/src/components/graph/selectionToolbox/BypassButton.test.ts
index c966e180a..9fdcd971f 100644
--- a/src/components/graph/selectionToolbox/BypassButton.test.ts
+++ b/src/components/graph/selectionToolbox/BypassButton.test.ts
@@ -84,18 +84,6 @@ describe('BypassButton', () => {
)
})
- it('should show normal styling when node is not bypassed', () => {
- const normalNode = { ...mockLGraphNode, mode: LGraphEventMode.ALWAYS }
- canvasStore.selectedItems = [normalNode] as any
-
- const wrapper = mountComponent()
- const button = wrapper.find('button')
-
- expect(button.classes()).not.toContain(
- 'dark-theme:[&:not(:active)]:!bg-[#262729]'
- )
- })
-
it('should show bypassed styling when node is bypassed', async () => {
const bypassedNode = { ...mockLGraphNode, mode: LGraphEventMode.BYPASS }
canvasStore.selectedItems = [bypassedNode] as any
diff --git a/src/components/graph/selectionToolbox/BypassButton.vue b/src/components/graph/selectionToolbox/BypassButton.vue
index 84882a285..f9fc19187 100644
--- a/src/components/graph/selectionToolbox/BypassButton.vue
+++ b/src/components/graph/selectionToolbox/BypassButton.vue
@@ -1,28 +1,23 @@
-
-
-
+
diff --git a/src/components/graph/selectionToolbox/ConfigureSubgraph.vue b/src/components/graph/selectionToolbox/ConfigureSubgraph.vue
index 5c361aa73..5535d7727 100644
--- a/src/components/graph/selectionToolbox/ConfigureSubgraph.vue
+++ b/src/components/graph/selectionToolbox/ConfigureSubgraph.vue
@@ -1,17 +1,23 @@
+ variant="muted-textonly"
+ :aria-label="$t('commands.Comfy_Graph_EditSubgraphWidgets.label')"
+ @click="handleClick"
+ >
+
+
diff --git a/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue b/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue
index 21779c35e..7352b54cf 100644
--- a/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue
+++ b/src/components/graph/selectionToolbox/ConvertToSubgraphButton.vue
@@ -2,44 +2,39 @@
commandStore.execute('Comfy.Graph.UnpackSubgraph')"
>
-
-
-
+
commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
>
-
-
-
+
diff --git a/src/components/graph/selectionToolbox/Load3DViewerButton.vue b/src/components/graph/selectionToolbox/Load3DViewerButton.vue
index 5187f0c02..22cc2d409 100644
--- a/src/components/graph/selectionToolbox/Load3DViewerButton.vue
+++ b/src/components/graph/selectionToolbox/Load3DViewerButton.vue
@@ -1,21 +1,19 @@
+ >
+
+
diff --git a/src/components/graph/selectionToolbox/NodeOptions.vue b/src/components/graph/selectionToolbox/NodeOptions.vue
deleted file mode 100644
index 9965ffa6d..000000000
--- a/src/components/graph/selectionToolbox/NodeOptions.vue
+++ /dev/null
@@ -1,324 +0,0 @@
-
-
-
-
-
diff --git a/src/components/graph/selectionToolbox/NodeOptionsButton.vue b/src/components/graph/selectionToolbox/NodeOptionsButton.vue
index 2b4e613c2..df70ad490 100644
--- a/src/components/graph/selectionToolbox/NodeOptionsButton.vue
+++ b/src/components/graph/selectionToolbox/NodeOptionsButton.vue
@@ -1,33 +1,23 @@
-
+
diff --git a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue
index ec0f44b2f..5edebc9e1 100644
--- a/src/components/graph/selectionToolbox/RefreshSelectionButton.vue
+++ b/src/components/graph/selectionToolbox/RefreshSelectionButton.vue
@@ -2,19 +2,19 @@
-
+
diff --git a/src/components/graph/widgets/DomWidget.vue b/src/components/graph/widgets/DomWidget.vue
index e28920018..f20aac370 100644
--- a/src/components/graph/widgets/DomWidget.vue
+++ b/src/components/graph/widgets/DomWidget.vue
@@ -143,8 +143,8 @@ onMounted(() => {
widget.options.selectOn ?? ['focus', 'click'],
() => {
const lgCanvas = canvasStore.canvas
- lgCanvas?.selectNode(widget.node)
- lgCanvas?.bringToFront(widget.node)
+ lgCanvas?.selectNode(widgetState.widget.node)
+ lgCanvas?.bringToFront(widgetState.widget.node)
}
)
})
diff --git a/src/components/graph/widgets/TextPreviewWidget.vue b/src/components/graph/widgets/TextPreviewWidget.vue
index 33ab74874..9e1709f2e 100644
--- a/src/components/graph/widgets/TextPreviewWidget.vue
+++ b/src/components/graph/widgets/TextPreviewWidget.vue
@@ -21,13 +21,38 @@ import { linkifyHtml, nl2br } from '@/utils/formatUtil'
const modelValue = defineModel
({ required: true })
const props = defineProps<{
- widget?: object
nodeId: NodeId
}>()
const executionStore = useExecutionStore()
const isParentNodeExecuting = ref(true)
-const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value)))
+const formattedText = computed(() => {
+ const src = modelValue.value
+ // Turn [[label|url]] into placeholders to avoid interfering with linkifyHtml
+ const tokens: { label: string; url: string }[] = []
+ const holed = src.replace(
+ /\[\[([^|\]]+)\|([^\]]+)\]\]/g,
+ (_m, label, url) => {
+ tokens.push({ label: String(label), url: String(url) })
+ return `__LNK${tokens.length - 1}__`
+ }
+ )
+
+ // Keep current behavior (auto-link bare URLs + \n -> )
+ let html = nl2br(linkifyHtml(holed))
+
+ // Restore placeholders as ... (minimal escaping + http default)
+ html = html.replace(/__LNK(\d+)__/g, (_m, i) => {
+ const { label, url } = tokens[+i]
+ const safeHref = url.replace(/"/g, '"')
+ const safeLabel = label.replace(//g, '>')
+ return /^https?:\/\//i.test(url)
+ ? `${safeLabel} `
+ : safeLabel
+ })
+
+ return html
+})
let parentNodeId: NodeId | null = null
onMounted(() => {
diff --git a/src/components/graph/widgets/chatHistory/CopyButton.vue b/src/components/graph/widgets/chatHistory/CopyButton.vue
deleted file mode 100644
index 933a1b921..000000000
--- a/src/components/graph/widgets/chatHistory/CopyButton.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
diff --git a/src/components/graph/widgets/chatHistory/ResponseBlurb.vue b/src/components/graph/widgets/chatHistory/ResponseBlurb.vue
deleted file mode 100644
index e2326c431..000000000
--- a/src/components/graph/widgets/chatHistory/ResponseBlurb.vue
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/src/components/helpcenter/HelpCenterMenuContent.vue b/src/components/helpcenter/HelpCenterMenuContent.vue
index bfa1cbc96..d912a2941 100644
--- a/src/components/helpcenter/HelpCenterMenuContent.vue
+++ b/src/components/helpcenter/HelpCenterMenuContent.vue
@@ -1,34 +1,52 @@
-