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/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..8d4f3673d
--- /dev/null
+++ b/src/components/TopMenuSection.test.ts
@@ -0,0 +1,232 @@
+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 { useSettingStore } from '@/platform/settings/settingStore'
+import { useCommandStore } from '@/stores/commandStore'
+import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
+import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
+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(pinia = createTestingPinia({ createSpy: vi.fn })) {
+ 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: [pinia, 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('hides queue progress overlay when QPO V2 is enabled', async () => {
+ const pinia = createTestingPinia({ createSpy: vi.fn })
+ const settingStore = useSettingStore(pinia)
+ vi.mocked(settingStore.get).mockImplementation((key) =>
+ key === 'Comfy.Queue.QPOV2' ? true : undefined
+ )
+ const wrapper = createWrapper(pinia)
+
+ await nextTick()
+
+ expect(wrapper.find('[data-testid="queue-overlay-toggle"]').exists()).toBe(
+ true
+ )
+ expect(
+ wrapper.findComponent({ name: 'QueueProgressOverlay' }).exists()
+ ).toBe(false)
+ })
+
+ it('toggles the queue progress overlay when QPO V2 is disabled', async () => {
+ const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
+ const settingStore = useSettingStore(pinia)
+ vi.mocked(settingStore.get).mockImplementation((key) =>
+ key === 'Comfy.Queue.QPOV2' ? false : undefined
+ )
+ const wrapper = createWrapper(pinia)
+ const commandStore = useCommandStore(pinia)
+
+ await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
+
+ expect(commandStore.execute).toHaveBeenCalledWith(
+ 'Comfy.Queue.ToggleOverlay'
+ )
+ })
+
+ it('opens the assets sidebar tab when QPO V2 is enabled', async () => {
+ const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
+ const settingStore = useSettingStore(pinia)
+ vi.mocked(settingStore.get).mockImplementation((key) =>
+ key === 'Comfy.Queue.QPOV2' ? true : undefined
+ )
+ const wrapper = createWrapper(pinia)
+ const sidebarTabStore = useSidebarTabStore(pinia)
+
+ await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
+
+ expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
+ })
+
+ it('toggles the assets sidebar tab when QPO V2 is enabled', async () => {
+ const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false })
+ const settingStore = useSettingStore(pinia)
+ vi.mocked(settingStore.get).mockImplementation((key) =>
+ key === 'Comfy.Queue.QPOV2' ? true : undefined
+ )
+ const wrapper = createWrapper(pinia)
+ const sidebarTabStore = useSidebarTabStore(pinia)
+ const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
+
+ await toggleButton.trigger('click')
+ expect(sidebarTabStore.activeSidebarTabId).toBe('assets')
+
+ await toggleButton.trigger('click')
+ expect(sidebarTabStore.activeSidebarTabId).toBe(null)
+ })
+
+ 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 55c78dd5a..05149589c 100644
--- a/src/components/TopMenuSection.vue
+++ b/src/components/TopMenuSection.vue
@@ -10,42 +10,84 @@
-
-
-
+
-
-
-
-
- {{ queuedCount }}
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ activeJobsLabel }}
+
+
+ {{
+ isQueuePanelV2Enabled
+ ? t('sideToolbar.queueProgressOverlay.viewJobHistory')
+ : t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
+ }}
+
+
+
+
+
+
+
+
+
@@ -54,38 +96,103 @@
-
-
diff --git a/src/components/actionbar/ComfyActionbar.vue b/src/components/actionbar/ComfyActionbar.vue
index d7d92c7de..5ea860852 100644
--- a/src/components/actionbar/ComfyActionbar.vue
+++ b/src/components/actionbar/ComfyActionbar.vue
@@ -1,5 +1,5 @@
-
+
-
@@ -43,22 +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)
@@ -66,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
@@ -126,7 +132,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 +268,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 +290,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 db28d980c..4c0ea84e4 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,25 +37,30 @@
diff --git a/src/components/breadcrumb/SubgraphBreadcrumb.vue b/src/components/breadcrumb/SubgraphBreadcrumb.vue
index 6795ee97d..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'
@@ -43,6 +64,7 @@ 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'
@@ -55,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)
@@ -62,17 +90,28 @@ 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'
@@ -95,21 +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: () => {
- useTelemetry()?.trackUiButtonClicked({
- button_id: 'breadcrumb_subgraph_root_selected'
- })
- 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
@@ -189,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);
}
@@ -205,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(--interface-stroke);
- border-bottom: 1px solid var(--interface-stroke);
+:deep(.p-breadcrumb-item:hover) {
+ @apply rounded-lg;
+ border-color: var(--interface-stroke);
background-color: var(--comfy-menu-bg);
}
@@ -218,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(--interface-stroke);
.p-breadcrumb-item-link {
padding-left: var(--p-breadcrumb-item-padding);
@@ -229,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(--interface-stroke);
}
-: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 2e8692558..0edcc25f9 100644
--- a/src/components/breadcrumb/SubgraphBreadcrumbItem.vue
+++ b/src/components/breadcrumb/SubgraphBreadcrumbItem.vue
@@ -7,7 +7,7 @@
}"
draggable="false"
href="#"
- class="p-breadcrumb-item-link h-12 cursor-pointer px-2"
+ class="p-breadcrumb-item-link h-8 cursor-pointer px-2"
:class="{
'flex items-center gap-1': isActive,
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
@@ -21,11 +21,11 @@
class="icon-[lucide--triangle-alert] text-warning-background"
/>
{{ item.label }}
-
+
(), {
isActive: false
})
-const { hasMissingNodes } = useMissingNodes()
+const nodeDefStore = useNodeDefStore()
+const hasMissingNodes = computed(() =>
+ graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
+)
const { t } = useI18n()
const menu = ref & MenuState>()
@@ -130,79 +136,28 @@ const tooltipText = computed(() => {
return props.item.label
})
-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')
+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`
}
- },
- {
- 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 { menuItems } = useWorkflowActionsMenu(startRename, { isRoot })
const handleClick = (event: MouseEvent) => {
if (isEditing.value) {
@@ -223,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)
@@ -244,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/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 0c6b26e4e..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],
diff --git a/src/components/common/UserCredit.test.ts b/src/components/common/UserCredit.test.ts
new file mode 100644
index 000000000..7fcb42f49
--- /dev/null
+++ b/src/components/common/UserCredit.test.ts
@@ -0,0 +1,134 @@
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createI18n } from 'vue-i18n'
+
+import enMessages from '@/locales/en/main.json' with { type: 'json' }
+
+import UserCredit from './UserCredit.vue'
+
+vi.mock('firebase/app', () => ({
+ 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 @@
-
@@ -382,19 +381,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'
@@ -405,6 +403,8 @@ import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
+import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
+import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
import { createGridStyle } from '@/utils/gridUtil'
@@ -423,6 +423,30 @@ onMounted(() => {
sessionStartTime.value = Date.now()
})
+const systemStatsStore = useSystemStatsStore()
+
+const distributions = computed(() => {
+ // eslint-disable-next-line no-undef
+ switch (__DISTRIBUTION__) {
+ case 'cloud':
+ return [TemplateIncludeOnDistributionEnum.Cloud]
+ case 'localhost':
+ return [TemplateIncludeOnDistributionEnum.Local]
+ case 'desktop':
+ default:
+ if (systemStatsStore.systemStats?.system.os === 'darwin') {
+ return [
+ TemplateIncludeOnDistributionEnum.Desktop,
+ TemplateIncludeOnDistributionEnum.Mac
+ ]
+ }
+ return [
+ TemplateIncludeOnDistributionEnum.Desktop,
+ TemplateIncludeOnDistributionEnum.Windows
+ ]
+ }
+})
+
// Wrap onClose to track session end
const onClose = () => {
if (isCloud) {
@@ -511,6 +535,9 @@ const allTemplates = computed(() => {
return workflowTemplatesStore.enhancedTemplates
})
+// Navigation
+const selectedNavItem = ref('all')
+
// Filter templates based on selected navigation item
const navigationFilteredTemplates = computed(() => {
if (!selectedNavItem.value) {
@@ -533,9 +560,40 @@ const {
availableRunsOn,
filteredCount,
totalCount,
- resetFilters
+ resetFilters,
+ loadFuseOptions
} = useTemplateFiltering(navigationFilteredTemplates)
+/**
+ * Coordinates state between the selected navigation item and the sort order to
+ * create deterministic, predictable behavior.
+ * @param source The origin of the change ('nav' or 'sort').
+ */
+const coordinateNavAndSort = (source: 'nav' | 'sort') => {
+ const isPopularNav = selectedNavItem.value === 'popular'
+ const isPopularSort = sortBy.value === 'popular'
+
+ if (source === 'nav') {
+ if (isPopularNav && !isPopularSort) {
+ // When navigating to 'Popular' category, automatically set sort to 'Popular'.
+ sortBy.value = 'popular'
+ } else if (!isPopularNav && isPopularSort) {
+ // When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
+ sortBy.value = 'default'
+ }
+ } else if (source === 'sort') {
+ // When sort is changed away from 'Popular' while in the 'Popular' category,
+ // reset the category to 'All Templates' to avoid a confusing state.
+ if (isPopularNav && !isPopularSort) {
+ selectedNavItem.value = 'all'
+ }
+ }
+}
+
+// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
+watch(selectedNavItem, () => coordinateNavAndSort('nav'))
+watch(sortBy, () => coordinateNavAndSort('sort'))
+
// Convert between string array and object array for MultiSelect component
const selectedModelObjects = computed({
get() {
@@ -578,9 +636,6 @@ const cardRefs = ref([])
// Force re-render key for templates when sorting changes
const templateListKey = ref(0)
-// Navigation
-const selectedNavItem = ref('all')
-
// Search text for model filter
const modelSearchText = ref('')
@@ -645,11 +700,19 @@ const runsOnFilterLabel = computed(() => {
// Sort options
const sortOptions = computed(() => [
- { name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.default', 'Default'),
value: 'default'
},
+ {
+ name: t('templateWorkflows.sort.recommended', 'Recommended'),
+ value: 'recommended'
+ },
+ {
+ name: t('templateWorkflows.sort.popular', 'Popular'),
+ value: 'popular'
+ },
+ { name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
value: 'vram-low-to-high'
@@ -750,10 +813,10 @@ const pageTitle = computed(() => {
// Initialize templates loading with useAsyncState
const { isLoading } = useAsyncState(
async () => {
- // Run both operations in parallel for better performance
await Promise.all([
loadTemplates(),
- workflowTemplatesStore.loadWorkflowTemplates()
+ workflowTemplatesStore.loadWorkflowTemplates(),
+ loadFuseOptions()
])
return true
},
@@ -763,6 +826,14 @@ const { isLoading } = useAsyncState(
}
)
+const isTemplateVisibleOnDistribution = (template: TemplateInfo) => {
+ return (template.includeOnDistributions?.length ?? 0) > 0
+ ? distributions.value.some((d) =>
+ template.includeOnDistributions?.includes(d)
+ )
+ : true
+}
+
onBeforeUnmount(() => {
cardRefs.value = [] // Release DOM refs
})
diff --git a/src/components/dialog/GlobalDialog.vue b/src/components/dialog/GlobalDialog.vue
index 4566b0684..aede76907 100644
--- a/src/components/dialog/GlobalDialog.vue
+++ b/src/components/dialog/GlobalDialog.vue
@@ -4,9 +4,14 @@
v-for="item in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
- class="global-dialog"
+ :class="[
+ 'global-dialog',
+ item.key === 'global-settings' && teamWorkspacesEnabled
+ ? 'settings-dialog-workspace'
+ : ''
+ ]"
v-bind="item.dialogComponentProps"
- :pt="item.dialogComponentProps.pt"
+ :pt="getDialogPt(item)"
:aria-labelledby="item.key"
>
@@ -14,6 +19,7 @@
@@ -35,11 +41,38 @@
diff --git a/src/components/honeyToast/HoneyToast.stories.ts b/src/components/honeyToast/HoneyToast.stories.ts
new file mode 100644
index 000000000..74331d49f
--- /dev/null
+++ b/src/components/honeyToast/HoneyToast.stories.ts
@@ -0,0 +1,293 @@
+import type { Meta, StoryObj } from '@storybook/vue3-vite'
+import { ref } from 'vue'
+
+import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
+import Button from '@/components/ui/button/Button.vue'
+import type { AssetDownload } from '@/stores/assetDownloadStore'
+import { cn } from '@/utils/tailwindUtil'
+
+import HoneyToast from './HoneyToast.vue'
+
+function createMockJob(overrides: Partial = {}): AssetDownload {
+ return {
+ taskId: 'task-1',
+ assetId: 'asset-1',
+ assetName: 'model-v1.safetensors',
+ bytesTotal: 1000000,
+ bytesDownloaded: 0,
+ progress: 0,
+ status: 'created',
+ lastUpdate: Date.now(),
+ ...overrides
+ }
+}
+
+const meta: Meta = {
+ title: 'Toast/HoneyToast',
+ component: HoneyToast,
+ parameters: {
+ layout: 'fullscreen'
+ },
+ decorators: [
+ () => ({
+ template: '
'
+ })
+ ]
+}
+
+export default meta
+type Story = StoryObj
+
+export const Default: Story = {
+ render: () => ({
+ components: { HoneyToast, Button, ProgressToastItem },
+ setup() {
+ const isExpanded = ref(false)
+ const jobs = [
+ createMockJob({
+ taskId: 'task-1',
+ assetName: 'model-v1.safetensors',
+ status: 'completed',
+ progress: 1
+ }),
+ createMockJob({
+ taskId: 'task-2',
+ assetName: 'lora-style.safetensors',
+ status: 'running',
+ progress: 0.45
+ }),
+ createMockJob({
+ taskId: 'task-3',
+ assetName: 'vae-decoder.safetensors',
+ status: 'created'
+ })
+ ]
+ return { isExpanded, cn, jobs }
+ },
+ template: `
+
+
+
+
Download Queue
+
+
+
+
+
+
+
+
+ lora-style.safetensors
+
+
+
+
+
+ `
+ })
+}
+
+export const Expanded: Story = {
+ render: () => ({
+ components: { HoneyToast, Button, ProgressToastItem },
+ setup() {
+ const isExpanded = ref(true)
+ const jobs = [
+ createMockJob({
+ taskId: 'task-1',
+ assetName: 'model-v1.safetensors',
+ status: 'completed',
+ progress: 1
+ }),
+ createMockJob({
+ taskId: 'task-2',
+ assetName: 'lora-style.safetensors',
+ status: 'running',
+ progress: 0.45
+ }),
+ createMockJob({
+ taskId: 'task-3',
+ assetName: 'vae-decoder.safetensors',
+ status: 'created'
+ })
+ ]
+ return { isExpanded, cn, jobs }
+ },
+ template: `
+
+
+
+
Download Queue
+
+
+
+
+
+
+
+
+ lora-style.safetensors
+
+
+
+
+
+ `
+ })
+}
+
+export const Completed: Story = {
+ render: () => ({
+ components: { HoneyToast, Button, ProgressToastItem },
+ setup() {
+ const isExpanded = ref(false)
+ const jobs = [
+ createMockJob({
+ taskId: 'task-1',
+ assetName: 'model-v1.safetensors',
+ bytesDownloaded: 1000000,
+ progress: 1,
+ status: 'completed'
+ }),
+ createMockJob({
+ taskId: 'task-2',
+ assetId: 'asset-2',
+ assetName: 'lora-style.safetensors',
+ bytesTotal: 500000,
+ bytesDownloaded: 500000,
+ progress: 1,
+ status: 'completed'
+ })
+ ]
+ return { isExpanded, cn, jobs }
+ },
+ template: `
+
+
+
+
Download Queue
+
+
+
+
+
+
+
+
+ All downloads completed
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+ })
+}
+
+export const WithError: Story = {
+ render: () => ({
+ components: { HoneyToast, Button, ProgressToastItem },
+ setup() {
+ const isExpanded = ref(true)
+ const jobs = [
+ createMockJob({
+ taskId: 'task-1',
+ assetName: 'model-v1.safetensors',
+ status: 'failed',
+ progress: 0.23
+ }),
+ createMockJob({
+ taskId: 'task-2',
+ assetName: 'lora-style.safetensors',
+ status: 'completed',
+ progress: 1
+ })
+ ]
+ return { isExpanded, cn, jobs }
+ },
+ template: `
+
+
+
+
Download Queue
+
+
+
+
+
+
+
+
+ 1 download failed
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+ })
+}
+
+export const Hidden: Story = {
+ render: () => ({
+ components: { HoneyToast },
+ template: `
+
+
HoneyToast is hidden when visible=false. Nothing appears at the bottom.
+
+
+
+ Content
+
+
+ Footer
+
+
+
+ `
+ })
+}
diff --git a/src/components/honeyToast/HoneyToast.test.ts b/src/components/honeyToast/HoneyToast.test.ts
new file mode 100644
index 000000000..ada123053
--- /dev/null
+++ b/src/components/honeyToast/HoneyToast.test.ts
@@ -0,0 +1,137 @@
+import type { VueWrapper } from '@vue/test-utils'
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { defineComponent, h, nextTick, ref } from 'vue'
+
+import HoneyToast from './HoneyToast.vue'
+
+describe('HoneyToast', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ document.body.innerHTML = ''
+ })
+
+ function mountComponent(
+ props: { visible: boolean; expanded?: boolean } = { visible: true }
+ ): VueWrapper {
+ return mount(HoneyToast, {
+ props,
+ slots: {
+ default: (slotProps: { isExpanded: boolean }) =>
+ h(
+ 'div',
+ { 'data-testid': 'content' },
+ slotProps.isExpanded ? 'expanded' : 'collapsed'
+ ),
+ footer: (slotProps: { isExpanded: boolean; toggle: () => void }) =>
+ h(
+ 'button',
+ {
+ 'data-testid': 'toggle-btn',
+ onClick: slotProps.toggle
+ },
+ slotProps.isExpanded ? 'Collapse' : 'Expand'
+ )
+ },
+ attachTo: document.body
+ })
+ }
+
+ it('renders when visible is true', async () => {
+ const wrapper = mountComponent({ visible: true })
+ await nextTick()
+
+ const toast = document.body.querySelector('[role="status"]')
+ expect(toast).toBeTruthy()
+
+ wrapper.unmount()
+ })
+
+ it('does not render when visible is false', async () => {
+ const wrapper = mountComponent({ visible: false })
+ await nextTick()
+
+ const toast = document.body.querySelector('[role="status"]')
+ expect(toast).toBeFalsy()
+
+ wrapper.unmount()
+ })
+
+ it('passes is-expanded=false to slots by default', async () => {
+ const wrapper = mountComponent({ visible: true })
+ await nextTick()
+
+ const content = document.body.querySelector('[data-testid="content"]')
+ expect(content?.textContent).toBe('collapsed')
+
+ wrapper.unmount()
+ })
+
+ it('applies collapsed max-height class when collapsed', async () => {
+ const wrapper = mountComponent({ visible: true, expanded: false })
+ await nextTick()
+
+ const expandableArea = document.body.querySelector(
+ '[role="status"] > div:first-child'
+ )
+ expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
+
+ wrapper.unmount()
+ })
+
+ it('has aria-live="polite" for accessibility', async () => {
+ const wrapper = mountComponent({ visible: true })
+ await nextTick()
+
+ const toast = document.body.querySelector('[role="status"]')
+ expect(toast?.getAttribute('aria-live')).toBe('polite')
+
+ wrapper.unmount()
+ })
+
+ it('supports v-model:expanded with reactive parent state', async () => {
+ const TestWrapper = defineComponent({
+ components: { HoneyToast },
+ setup() {
+ const expanded = ref(false)
+ return { expanded }
+ },
+ template: `
+
+
+ {{ slotProps.isExpanded ? 'expanded' : 'collapsed' }}
+
+
+
+ {{ slotProps.isExpanded ? 'Collapse' : 'Expand' }}
+
+
+
+ `
+ })
+
+ const wrapper = mount(TestWrapper, { attachTo: document.body })
+ await nextTick()
+
+ const content = document.body.querySelector('[data-testid="content"]')
+ expect(content?.textContent).toBe('collapsed')
+
+ const toggleBtn = document.body.querySelector(
+ '[data-testid="toggle-btn"]'
+ ) as HTMLButtonElement
+ expect(toggleBtn?.textContent?.trim()).toBe('Expand')
+
+ toggleBtn?.click()
+ await nextTick()
+
+ expect(content?.textContent).toBe('expanded')
+ expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
+
+ const expandableArea = document.body.querySelector(
+ '[role="status"] > div:first-child'
+ )
+ expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
+
+ wrapper.unmount()
+ })
+})
diff --git a/src/components/honeyToast/HoneyToast.vue b/src/components/honeyToast/HoneyToast.vue
new file mode 100644
index 000000000..a7d86ba77
--- /dev/null
+++ b/src/components/honeyToast/HoneyToast.vue
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/imagecrop/WidgetImageCrop.vue b/src/components/imagecrop/WidgetImageCrop.vue
new file mode 100644
index 000000000..4a1c39ef6
--- /dev/null
+++ b/src/components/imagecrop/WidgetImageCrop.vue
@@ -0,0 +1,100 @@
+
+
@@ -145,9 +145,13 @@
-
+
-
{{ slotProps.option.name }}
+
+ {{ slotProps.option.name }}
+
@@ -172,17 +174,16 @@
diff --git a/src/components/load3d/Load3D.vue b/src/components/load3d/Load3D.vue
index a525481d4..f6942e7c3 100644
--- a/src/components/load3d/Load3D.vue
+++ b/src/components/load3d/Load3D.vue
@@ -1,6 +1,6 @@