+
-
@@ -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,21 +268,32 @@ 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'
+ ? '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 8907ecf44..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/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 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 @@