App mode - Unify menus - 2 (#9023)

## Summary

Updates subgraph breadcrumbs menu, workflow tabs context menu & linear
mode menu to use a single implementation.
Adds new menu items for enter/exit app mode  
Hides menu when in builder mode

## Changes

- **What**: Changes the components to use either a reka-ui context menu
or dropdown, with a standard inner list
- **Breaking**: Remove existing linear toggle from sidebar as it is now
in the menu


## Screenshots (if applicable)
It looks basically identical other than the icon changes based on mode:

In Graph Mode:
<img width="261" height="497" alt="image"
src="https://github.com/user-attachments/assets/eb9968a2-b528-4e21-9e14-ab4a67e717ae"
/>

In App Mode:
<img width="254" height="499" alt="image"
src="https://github.com/user-attachments/assets/54a89fab-e7b2-4cb0-bcb7-43d6d076ac83"
/>

Right click tab:
<img width="321" height="564" alt="image"
src="https://github.com/user-attachments/assets/c12c7d64-2dba-45bb-be76-2615f3e38cc6"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9023-App-mode-Unify-menus-2-30d6d73d36508162bfc0e308d5f705de)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
pythongosssss
2026-02-23 17:49:52 +00:00
committed by GitHub
parent ddcfdb924d
commit d601aba721
36 changed files with 892 additions and 268 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

View File

@@ -1,6 +1,6 @@
<template>
<div
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)]"
class="subgraph-breadcrumb flex w-auto drop-shadow-[var(--interface-panel-drop-shadow)] items-center"
:class="{
'subgraph-breadcrumb-collapse': collapseTabs,
'subgraph-breadcrumb-overflow': overflowingTabs
@@ -13,17 +13,10 @@
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
}"
>
<Button
class="context-menu-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
icon="pi pi-bars"
text
severity="secondary"
size="small"
@click="handleMenuClick"
/>
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
<Button
v-if="isInSubgraph"
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
class="back-button pointer-events-auto h-8 w-8 shrink-0 border border-transparent bg-transparent p-0 ml-1.5 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
text
severity="secondary"
size="small"
@@ -41,7 +34,6 @@
>
<template #item="{ item }">
<SubgraphBreadcrumbItem
:ref="(el) => setItemRef(item, el)"
:item="item"
:is-active="item.key === activeItemKey"
/>
@@ -60,6 +52,7 @@ import type { MenuItem } from 'primevue/menuitem'
import { computed, onUpdated, ref, watch } from 'vue'
import SubgraphBreadcrumbItem from '@/components/breadcrumb/SubgraphBreadcrumbItem.vue'
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -77,12 +70,6 @@ const ICON_WIDTH = 20
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const rootItemRef = ref<InstanceType<typeof SubgraphBreadcrumbItem>>()
const setItemRef = (item: MenuItem, el: unknown) => {
if (item.key === 'root') {
rootItemRef.value = el as InstanceType<typeof SubgraphBreadcrumbItem>
}
}
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const isBlueprint = computed(() =>
useSubgraphStore().isSubgraphBlueprint(workflowStore.activeWorkflow)
@@ -136,13 +123,6 @@ const items = computed(() => {
const activeItemKey = computed(() => items.value.at(-1)?.key)
const handleMenuClick = (event: MouseEvent) => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_menu_selected'
})
rootItemRef.value?.toggleMenu(event)
}
const handleBackClick = () => {
void useCommandStore().execute('Comfy.Graph.ExitSubgraph')
}

View File

@@ -185,14 +185,6 @@ const inputBlur = async (doRename: boolean) => {
isEditing.value = false
}
const toggleMenu = (event: MouseEvent) => {
menu.value?.toggle(event)
}
defineExpose({
toggleMenu
})
</script>
<style scoped>

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import {
DropdownMenuContent,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
const { source, align = 'start' } = defineProps<{
source: string
align?: 'start' | 'center' | 'end'
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const { menuItems } = useWorkflowActionsMenu(
() => useCommandStore().execute('Comfy.RenameWorkflow'),
{ isRoot: true }
)
function handleOpen(open: boolean) {
if (open) {
useTelemetry()?.trackUiButtonClicked({
button_id: source
})
}
}
</script>
<template>
<DropdownMenuRoot @update:open="handleOpen">
<DropdownMenuTrigger as-child>
<slot name="button">
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
showDelay: 300,
hideDelay: 300
}"
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="h-10 rounded-lg pl-3 pr-2 pointer-events-auto gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<i
class="size-4"
:class="
canvasStore.linearMode
? 'icon-[lucide--panels-top-left]'
: 'icon-[comfy--workflow]'
"
/>
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
</Button>
</slot>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:align
:side-offset="5"
:collision-padding="10"
class="z-1000 rounded-lg px-2 py-3 min-w-56 bg-base-background shadow-interface border border-border-subtle"
>
<WorkflowActionsList :items="menuItems" />
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>

View File

@@ -0,0 +1,81 @@
import { shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import type {
WorkflowMenuAction,
WorkflowMenuItem
} from '@/types/workflowMenuItem'
function createWrapper(items: WorkflowMenuItem[]) {
return shallowMount(WorkflowActionsList, {
props: { items },
global: { renderStubDefaultSlot: true }
})
}
describe('WorkflowActionsList', () => {
it('renders action items with label and icon', () => {
const items: WorkflowMenuItem[] = [
{ label: 'Save', icon: 'pi pi-save', command: vi.fn() }
]
const wrapper = createWrapper(items)
expect(wrapper.text()).toContain('Save')
expect(wrapper.find('.pi-save').exists()).toBe(true)
})
it('renders separator items', () => {
const items: WorkflowMenuItem[] = [
{ label: 'Before', icon: 'pi pi-a', command: vi.fn() },
{ separator: true },
{ label: 'After', icon: 'pi pi-b', command: vi.fn() }
]
const wrapper = createWrapper(items)
const html = wrapper.html()
expect(html).toContain('dropdown-menu-separator-stub')
expect(wrapper.text()).toContain('Before')
expect(wrapper.text()).toContain('After')
})
it('dispatches command on select', async () => {
const command = vi.fn()
const items: WorkflowMenuItem[] = [
{ label: 'Action', icon: 'pi pi-play', command }
]
const wrapper = createWrapper(items)
const item = wrapper.findComponent({ name: 'DropdownMenuItem' })
await item.vm.$emit('select')
expect(command).toHaveBeenCalledOnce()
})
it('renders badge when present', () => {
const items: WorkflowMenuItem[] = [
{
label: 'New Feature',
icon: 'pi pi-star',
command: vi.fn(),
badge: 'NEW'
}
]
const wrapper = createWrapper(items)
expect(wrapper.text()).toContain('NEW')
})
it('does not render badge when absent', () => {
const items: WorkflowMenuAction[] = [
{ label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
]
const wrapper = createWrapper(items)
expect(wrapper.text()).not.toContain('NEW')
})
})

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { DropdownMenuItem, DropdownMenuSeparator } from 'reka-ui'
import type { Component } from 'vue'
import OverlayIcon from '@/components/common/OverlayIcon.vue'
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
import { cn } from '@/utils/tailwindUtil'
const {
items,
itemComponent = DropdownMenuItem,
separatorComponent = DropdownMenuSeparator
} = defineProps<{
items: WorkflowMenuItem[]
itemComponent?: Component
separatorComponent?: Component
}>()
</script>
<template>
<template v-for="(item, index) in items" :key="index">
<component
:is="separatorComponent"
v-if="item.separator"
class="border-b w-full border-border-subtle my-1"
/>
<component
:is="itemComponent"
v-else
:disabled="item.disabled"
:class="
cn(
'flex min-h-6 p-2 items-center gap-2 self-stretch rounded-sm outline-none',
!item.disabled && item.command && 'cursor-pointer',
'data-[highlighted]:bg-secondary-background-hover',
!item.disabled && 'hover:bg-secondary-background-hover',
'data-[disabled]:opacity-50 data-[disabled]:cursor-default'
)
"
@select="() => item.command?.()"
>
<OverlayIcon v-if="item.overlayIcon" v-bind="item.overlayIcon" />
<i v-else-if="item.icon" :class="item.icon" />
<span class="flex-1">{{ item.label }}</span>
<span
v-if="item.badge"
class="rounded-full uppercase ml-3 flex items-center gap-1 bg-[var(--primary-background)] px-1.5 py-0.5 text-xxs text-base-foreground"
>
{{ item.badge }}
</span>
</component>
</template>
</template>

View File

@@ -45,9 +45,6 @@
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarSettingsButton :is-small="isSmall" />
<ModeToggle
v-if="menuItemStore.hasSeenLinear || flags.linearToggleEnabled"
/>
</div>
</div>
<HelpCenterPopups :is-small="isSmall" />
@@ -62,18 +59,15 @@ import { useI18n } from 'vue-i18n'
import HelpCenterPopups from '@/components/helpcenter/HelpCenterPopups.vue'
import ComfyMenuButton from '@/components/sidebar/ComfyMenuButton.vue'
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
import SidebarBottomPanelToggleButton from '@/components/sidebar/SidebarBottomPanelToggleButton.vue'
import SidebarSettingsButton from '@/components/sidebar/SidebarSettingsButton.vue'
import SidebarShortcutsToggleButton from '@/components/sidebar/SidebarShortcutsToggleButton.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useUserStore } from '@/stores/userStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { SidebarTabExtension } from '@/types/extensionTypes'
@@ -90,11 +84,9 @@ const settingStore = useSettingStore()
const userStore = useUserStore()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const menuItemStore = useMenuItemStore()
const sideToolbarRef = ref<HTMLElement>()
const topToolbarRef = ref<HTMLElement>()
const bottomToolbarRef = ref<HTMLElement>()
const { flags } = useFeatureFlags()
const isSmall = computed(
() => settingStore.get('Comfy.Sidebar.Size') === 'small'

View File

@@ -1,36 +1,53 @@
<template>
<div
ref="workflowTabRef"
class="workflow-tab group flex gap-2 p-2"
v-bind="$attrs"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@click="handleClick"
>
<i
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
class="icon-[lucide--panels-top-left] bg-primary-background"
/>
<span class="workflow-label inline-block max-w-[150px] truncate text-sm">
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
<span
v-if="shouldShowStatusIndicator"
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
></span
<ContextMenuRoot>
<ContextMenuTrigger as-child>
<div
ref="workflowTabRef"
class="workflow-tab group flex gap-2 p-2"
v-bind="$attrs"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@click="handleClick"
>
<Button
class="close-button invisible w-auto p-0"
variant="muted-textonly"
size="icon-sm"
:aria-label="t('g.close')"
@click.stop="onCloseWorkflow(workflowOption)"
<i
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
class="icon-[lucide--panels-top-left] bg-primary-background"
/>
<span
class="workflow-label inline-block max-w-[150px] truncate text-sm"
>
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
<span
v-if="shouldShowStatusIndicator"
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
></span
>
<Button
class="close-button invisible w-auto p-0"
variant="muted-textonly"
size="icon-sm"
:aria-label="t('g.close')"
@click.stop="onCloseWorkflow(workflowOption)"
>
<i class="pi pi-times" />
</Button>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuPortal>
<ContextMenuContent
class="z-1000 rounded-lg px-2 py-3 min-w-56 bg-base-background shadow-interface border border-border-subtle"
>
<i class="pi pi-times" />
</Button>
</div>
</div>
<WorkflowActionsList
:items="contextMenuItems"
:item-component="ContextMenuItem"
:separator-component="ContextMenuSeparator"
/>
</ContextMenuContent>
</ContextMenuPortal>
</ContextMenuRoot>
<WorkflowTabPopover
ref="popoverRef"
@@ -41,20 +58,32 @@
</template>
<script setup lang="ts">
import {
ContextMenuContent,
ContextMenuItem,
ContextMenuPortal,
ContextMenuRoot,
ContextMenuSeparator,
ContextMenuTrigger
} from 'reka-ui'
import { computed, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import Button from '@/components/ui/button/Button.vue'
import {
usePragmaticDraggable,
usePragmaticDroppable
} from '@/composables/usePragmaticDragAndDrop'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
@@ -65,6 +94,8 @@ interface WorkflowOption {
const props = defineProps<{
workflowOption: WorkflowOption
isFirst: boolean
isLast: boolean
}>()
const { t } = useI18n()
@@ -148,6 +179,67 @@ const closeWorkflows = async (options: WorkflowOption[]) => {
const onCloseWorkflow = async (option: WorkflowOption) => {
await closeWorkflows([option])
}
const emit = defineEmits<{
closeToLeft: []
closeToRight: []
closeOthers: []
}>()
const commandStore = useCommandStore()
const workflow = computed(() => props.workflowOption.workflow)
const { menuItems: baseMenuItems } = useWorkflowActionsMenu(
() => commandStore.execute('Comfy.RenameWorkflow'),
{ includeDelete: false, workflow }
)
const contextMenuItems = computed<WorkflowMenuItem[]>(() => [
...baseMenuItems.value,
{ separator: true },
{
label: t('tabMenu.closeTab'),
icon: 'pi pi-times',
command: () => onCloseWorkflow(props.workflowOption)
},
{
label: t('tabMenu.closeTabsToLeft'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrow-left',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
},
command: () => emit('closeToLeft'),
disabled: props.isFirst
},
{
label: t('tabMenu.closeTabsToRight'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrow-right',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
},
command: () => emit('closeToRight'),
disabled: props.isLast
},
{
label: t('tabMenu.closeOtherTabs'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrows-h',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
},
command: () => emit('closeOthers'),
disabled: props.isFirst && props.isLast
}
])
const tabGetter = () => workflowTabRef.value as HTMLElement
usePragmaticDraggable(tabGetter, {

View File

@@ -32,11 +32,20 @@
data-key="value"
@update:model-value="onWorkflowChange"
>
<template #option="{ option }">
<template #option="{ option, index }">
<WorkflowTab
:workflow-option="option"
@contextmenu="showContextMenu($event, option)"
:is-first="index === 0"
:is-last="index === options.length - 1"
@click.middle="onCloseWorkflow(option)"
@close-to-left="closeWorkflows(options.slice(0, index))"
@close-to-right="closeWorkflows(options.slice(index + 1))"
@close-others="
closeWorkflows([
...options.slice(index + 1),
...options.slice(0, index)
])
"
/>
</template>
</SelectButton>
@@ -58,7 +67,10 @@
:active-workflow="workflowStore.activeWorkflow"
/>
<Button
v-tooltip="{ value: $t('sideToolbar.newBlankWorkflow'), showDelay: 300 }"
v-tooltip="{
value: $t('sideToolbar.newBlankWorkflow'),
showDelay: 300
}"
class="new-blank-workflow-button no-drag shrink-0 rounded-none h-full w-auto aspect-square"
variant="muted-textonly"
size="icon"
@@ -80,27 +92,17 @@
/>
<LoginButton v-else-if="isDesktop" class="p-1" />
</div>
<ContextMenu ref="menu" :model="contextMenuItems">
<template #itemicon="{ item }">
<OverlayIcon v-if="item.overlayIcon" v-bind="item.overlayIcon" />
<i v-else-if="item.icon" :class="item.icon" />
</template>
</ContextMenu>
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
</div>
</template>
<script setup lang="ts">
import { useScroll } from '@vueuse/core'
import ContextMenu from 'primevue/contextmenu'
import ScrollPanel from 'primevue/scrollpanel'
import SelectButton from 'primevue/selectbutton'
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { useI18n } from 'vue-i18n'
import OverlayIcon from '@/components/common/OverlayIcon.vue'
import type { OverlayIconProps } from '@/components/common/OverlayIcon.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import TopMenuHelpButton from '@/components/topbar/TopMenuHelpButton.vue'
@@ -108,7 +110,6 @@ import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -129,7 +130,6 @@ const props = defineProps<{
class?: string
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
@@ -141,8 +141,6 @@ const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
)
const rightClickedTab = ref<WorkflowOption | undefined>()
const menu = ref()
const containerRef = ref<HTMLElement | null>(null)
const showOverflowArrows = ref(false)
const leftArrowEnabled = ref(false)
@@ -192,78 +190,6 @@ const onCloseWorkflow = async (option: WorkflowOption) => {
await closeWorkflows([option])
}
const showContextMenu = (event: MouseEvent, option: WorkflowOption) => {
rightClickedTab.value = option
menu.value.show(event)
}
const rightClickedWorkflow = computed(
() => rightClickedTab.value?.workflow ?? null
)
const { menuItems: baseMenuItems } = useWorkflowActionsMenu(
() => commandStore.execute('Comfy.RenameWorkflow'),
{
includeDelete: false,
workflow: rightClickedWorkflow
}
)
const contextMenuItems = computed(() => {
const tab = rightClickedTab.value
if (!tab) return []
const index = options.value.findIndex((v) => v.workflow === tab.workflow)
return [
...baseMenuItems.value,
{
label: t('tabMenu.closeTab'),
icon: 'pi pi-times',
command: () => onCloseWorkflow(tab)
},
{
label: t('tabMenu.closeTabsToLeft'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrow-left',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
} as OverlayIconProps,
command: () => closeWorkflows(options.value.slice(0, index)),
disabled: index <= 0
},
{
label: t('tabMenu.closeTabsToRight'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrow-right',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
} as OverlayIconProps,
command: () => closeWorkflows(options.value.slice(index + 1)),
disabled: index === options.value.length - 1
},
{
label: t('tabMenu.closeOtherTabs'),
overlayIcon: {
mainIcon: 'pi pi-times',
subIcon: 'pi pi-arrows-h',
positionX: 'right',
positionY: 'bottom',
subIconScale: 0.5
} as OverlayIconProps,
command: () =>
closeWorkflows([
...options.value.slice(index + 1),
...options.value.slice(0, index)
]),
disabled: options.value.length <= 1
}
]
})
// Horizontal scroll on wheel
const handleWheel = (event: WheelEvent) => {
const scrollElement = event.currentTarget as HTMLElement

View File

@@ -0,0 +1,346 @@
import { createPinia, setActivePinia } from 'pinia'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { WorkflowMenuAction } from '@/types/workflowMenuItem'
vi.mock('vue-i18n', () => ({
useI18n: vi.fn(() => ({
t: (key: string) => key
}))
}))
const mockBookmarkStore = vi.hoisted(() => ({
isBookmarked: vi.fn(() => false),
toggleBookmarked: vi.fn()
}))
const mockWorkflowStore = vi.hoisted(() => ({
activeWorkflow: { path: 'test.json', isPersisted: true } as ComfyWorkflow
}))
const mockWorkflowService = vi.hoisted(() => ({
openWorkflow: vi.fn(),
duplicateWorkflow: vi.fn(),
saveWorkflowAs: vi.fn(),
deleteWorkflow: vi.fn()
}))
const mockCommandStore = vi.hoisted(() => ({
execute: vi.fn()
}))
const mockSubgraphStore = vi.hoisted(() => ({
isSubgraphBlueprint: vi.fn(() => false)
}))
const mockMenuItemStore = vi.hoisted(() => ({
hasSeenLinear: false
}))
const mockCanvasStore = vi.hoisted(() => ({
linearMode: false
}))
const mockFeatureFlags = vi.hoisted(() => ({
flags: { linearToggleEnabled: false }
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: vi.fn(() => mockWorkflowStore),
useWorkflowBookmarkStore: vi.fn(() => mockBookmarkStore)
}))
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: vi.fn(() => mockWorkflowService)
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: vi.fn(() => mockCommandStore)
}))
vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: vi.fn(() => mockSubgraphStore)
}))
vi.mock('@/stores/menuItemStore', () => ({
useMenuItemStore: vi.fn(() => mockMenuItemStore)
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => mockCanvasStore)
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => mockFeatureFlags)
}))
type MenuItems = ReturnType<typeof useWorkflowActionsMenu>['menuItems']['value']
function actionItems(items: MenuItems): WorkflowMenuAction[] {
return items.filter((i): i is WorkflowMenuAction => !i.separator)
}
function menuLabels(items: MenuItems) {
return actionItems(items).map((i) => i.label)
}
function findItem(items: MenuItems, label: string): WorkflowMenuAction {
const item = actionItems(items).find((i) => i.label === label)
if (!item) throw new Error(`Menu item "${label}" not found`)
return item
}
describe('useWorkflowActionsMenu', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockBookmarkStore.isBookmarked.mockReturnValue(false)
mockSubgraphStore.isSubgraphBlueprint.mockReturnValue(false)
mockMenuItemStore.hasSeenLinear = false
mockCanvasStore.linearMode = false
mockFeatureFlags.flags.linearToggleEnabled = false
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
isPersisted: true
} as ComfyWorkflow
})
it('shows root-level items by default', () => {
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('g.rename')
expect(labels).toContain('breadcrumbsMenu.duplicate')
expect(labels).toContain('menuLabels.Save')
expect(labels).toContain('menuLabels.Save As')
expect(labels).toContain('menuLabels.Export')
expect(labels).toContain('menuLabels.Export (API)')
expect(labels).toContain('breadcrumbsMenu.clearWorkflow')
expect(labels).toContain('breadcrumbsMenu.deleteWorkflow')
})
it('hides root-only items when isRoot is false', () => {
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: false })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('g.rename')
expect(labels).toContain('breadcrumbsMenu.clearWorkflow')
expect(labels).not.toContain('breadcrumbsMenu.duplicate')
expect(labels).not.toContain('menuLabels.Save')
expect(labels).not.toContain('menuLabels.Save As')
})
it('hides delete item when includeDelete is false', () => {
const { menuItems } = useWorkflowActionsMenu(vi.fn(), {
isRoot: true,
includeDelete: false
})
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('breadcrumbsMenu.deleteWorkflow')
})
it('shows app mode items when linearToggleEnabled flag is set', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('breadcrumbsMenu.enterAppMode')
})
it('shows app mode items when user has seen linear mode', () => {
mockMenuItemStore.hasSeenLinear = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('breadcrumbsMenu.enterAppMode')
})
it('hides app mode items when conditions not met', () => {
mockMenuItemStore.hasSeenLinear = false
mockFeatureFlags.flags.linearToggleEnabled = false
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('breadcrumbsMenu.enterAppMode')
})
it('hides app mode items when not root', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: false })
const labels = menuLabels(menuItems.value)
expect(labels).not.toContain('breadcrumbsMenu.enterAppMode')
})
it('shows "go to workflow mode" when in linear mode', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
mockCanvasStore.linearMode = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('breadcrumbsMenu.exitAppMode')
expect(labels).not.toContain('breadcrumbsMenu.enterAppMode')
})
it('shows bookmark label based on bookmark state', () => {
mockBookmarkStore.isBookmarked.mockReturnValue(true)
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('tabMenu.removeFromBookmarks')
expect(labels).not.toContain('tabMenu.addToBookmarks')
})
it('adds badge to app mode items', () => {
mockFeatureFlags.flags.linearToggleEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const appModeItem = findItem(
menuItems.value,
'breadcrumbsMenu.enterAppMode'
)
expect(appModeItem.badge).toBeDefined()
})
it('calls startRename when rename command is invoked', async () => {
const startRename = vi.fn()
const { menuItems } = useWorkflowActionsMenu(startRename, {
isRoot: true
})
await findItem(menuItems.value, 'g.rename').command?.()
expect(startRename).toHaveBeenCalled()
})
it('uses provided workflow ref instead of activeWorkflow', () => {
const customWorkflow = ref({
path: 'custom.json',
isPersisted: true,
isTemporary: false
} as ComfyWorkflow)
mockBookmarkStore.isBookmarked.mockReturnValue(false)
const { menuItems } = useWorkflowActionsMenu(vi.fn(), {
isRoot: true,
workflow: customWorkflow
})
expect(menuItems.value.length).toBeGreaterThan(0)
expect(mockBookmarkStore.isBookmarked).toHaveBeenCalledWith('custom.json')
})
it('shows publish item for blueprints', () => {
mockSubgraphStore.isSubgraphBlueprint.mockReturnValue(true)
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const labels = menuLabels(menuItems.value)
expect(labels).toContain('subgraphStore.publish')
expect(labels).toContain('breadcrumbsMenu.deleteBlueprint')
expect(labels).not.toContain('breadcrumbsMenu.duplicate')
})
it('duplicate command calls workflowService.duplicateWorkflow', async () => {
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
await findItem(menuItems.value, 'breadcrumbsMenu.duplicate').command?.()
expect(mockWorkflowService.duplicateWorkflow).toHaveBeenCalledWith(
mockWorkflowStore.activeWorkflow
)
})
it('save command executes Comfy.SaveWorkflow', async () => {
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
await findItem(menuItems.value, 'menuLabels.Save').command?.()
expect(mockCommandStore.execute).toHaveBeenCalledWith('Comfy.SaveWorkflow')
})
it('delete command calls workflowService.deleteWorkflow', async () => {
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
await findItem(
menuItems.value,
'breadcrumbsMenu.deleteWorkflow'
).command?.()
expect(mockWorkflowService.deleteWorkflow).toHaveBeenCalledWith(
mockWorkflowStore.activeWorkflow
)
})
it('bookmark toggle calls bookmarkStore.toggleBookmarked', async () => {
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
await findItem(menuItems.value, 'tabMenu.addToBookmarks').command?.()
expect(mockBookmarkStore.toggleBookmarked).toHaveBeenCalledWith('test.json')
})
it('app mode toggle executes Comfy.ToggleLinear', async () => {
mockFeatureFlags.flags.linearToggleEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
await findItem(menuItems.value, 'breadcrumbsMenu.enterAppMode').command?.()
expect(mockCommandStore.execute).toHaveBeenCalledWith(
'Comfy.ToggleLinear',
{ metadata: { source: 'breadcrumb_menu' } }
)
})
it('rename is disabled for unpersisted root workflows', () => {
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
isPersisted: false
} as ComfyWorkflow
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const rename = findItem(menuItems.value, 'g.rename')
expect(rename.disabled).toBe(true)
})
it('bookmark is disabled for temporary workflows', () => {
mockWorkflowStore.activeWorkflow = {
path: 'test.json',
isPersisted: true,
isTemporary: true
} as ComfyWorkflow
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
const bookmark = findItem(menuItems.value, 'tabMenu.addToBookmarks')
expect(bookmark.disabled).toBe(true)
})
it('switches to custom workflow before executing rename', async () => {
const customWorkflow = ref({
path: 'other.json',
isPersisted: true
} as ComfyWorkflow)
const startRename = vi.fn()
const { menuItems } = useWorkflowActionsMenu(startRename, {
isRoot: true,
workflow: customWorkflow
})
await findItem(menuItems.value, 'g.rename').command?.()
expect(mockWorkflowService.openWorkflow).toHaveBeenCalledWith(
customWorkflow.value
)
expect(startRename).toHaveBeenCalled()
})
})

View File

@@ -1,16 +1,22 @@
import type { MenuItem } from 'primevue/menuitem'
import type { ComputedRef, Ref } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import {
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import type {
WorkflowMenuAction,
WorkflowMenuItem
} from '@/types/workflowMenuItem'
interface WorkflowActionsMenuOptions {
/** Whether this is the root workflow level. Defaults to true. */
@@ -21,6 +27,16 @@ interface WorkflowActionsMenuOptions {
workflow?: Ref<ComfyWorkflow | null> | ComputedRef<ComfyWorkflow | null>
}
interface AddItemOptions {
label: string
icon: string
command: () => void
visible?: boolean
disabled?: boolean
prependSeparator?: boolean
isNew?: boolean
}
export function useWorkflowActionsMenu(
startRename: () => void,
options: WorkflowActionsMenuOptions = {}
@@ -32,6 +48,9 @@ export function useWorkflowActionsMenu(
const bookmarkStore = useWorkflowBookmarkStore()
const commandStore = useCommandStore()
const subgraphStore = useSubgraphStore()
const menuItemStore = useMenuItemStore()
const canvasStore = useCanvasStore()
const { flags } = useFeatureFlags()
const targetWorkflow = computed(
() => workflow?.value ?? workflowStore.activeWorkflow
@@ -43,145 +62,166 @@ export function useWorkflowActionsMenu(
await workflowService.openWorkflow(wf)
}
const menuItems = computed<MenuItem[]>(() => {
const menuItems = computed<WorkflowMenuItem[]>(() => {
const workflow = targetWorkflow.value
const isBlueprint = workflow
? subgraphStore.isSubgraphBlueprint(workflow)
: false
const items: MenuItem[] = []
const items: WorkflowMenuItem[] = []
const addItem = (
label: string,
icon: string,
command: () => void,
const addItem = ({
label,
icon,
command,
visible = true,
disabled = false,
separator = false
) => {
prependSeparator = false,
isNew = false
}: AddItemOptions) => {
if (!visible) return
if (separator) items.push({ separator: true })
items.push({ label, icon, command, disabled })
if (prependSeparator) items.push({ separator: true })
const item: WorkflowMenuAction = { label, icon, command, disabled }
if (isNew) {
item.badge = t('contextMenu.new')
}
items.push(item)
}
addItem(
t('g.rename'),
'pi pi-pencil',
async () => {
const isLinearMode = canvasStore.linearMode
const showAppModeItems =
isRoot && (menuItemStore.hasSeenLinear || flags.linearToggleEnabled)
const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '')
addItem({
label: t('g.rename'),
icon: 'pi pi-pencil',
command: async () => {
await ensureWorkflowActive(targetWorkflow.value)
startRename()
},
true,
isRoot && !workflow?.isPersisted
)
disabled: isRoot && !workflow?.isPersisted
})
addItem(
t('breadcrumbsMenu.duplicate'),
'pi pi-copy',
async () => {
addItem({
label: t('breadcrumbsMenu.duplicate'),
icon: 'pi pi-copy',
command: async () => {
if (workflow) {
await workflowService.duplicateWorkflow(workflow)
}
},
isRoot && !isBlueprint
)
visible: isRoot && !isBlueprint
})
addItem(
t('menuLabels.Save'),
'pi pi-save',
async () => {
await ensureWorkflowActive(workflow)
await commandStore.execute('Comfy.SaveWorkflow')
},
isRoot,
false,
true
)
addItem(
t('menuLabels.Save As'),
'pi pi-save',
async () => {
await ensureWorkflowActive(workflow)
await commandStore.execute('Comfy.SaveWorkflowAs')
},
isRoot
)
addItem(
bookmarkStore.isBookmarked(workflow?.path ?? '')
addItem({
label: isBookmarked
? t('tabMenu.removeFromBookmarks')
: t('tabMenu.addToBookmarks'),
'pi pi-bookmark' +
(bookmarkStore.isBookmarked(workflow?.path ?? '') ? '-fill' : ''),
async () => {
icon: 'pi pi-bookmark' + (isBookmarked ? '-fill' : ''),
command: async () => {
if (workflow?.path) {
await bookmarkStore.toggleBookmarked(workflow.path)
}
},
isRoot,
workflow?.isTemporary ?? false
)
visible: isRoot,
disabled: workflow?.isTemporary ?? false
})
addItem(
t('menuLabels.Export'),
'pi pi-download',
async () => {
addItem({
label: t('menuLabels.Save'),
icon: 'pi pi-save',
command: async () => {
await ensureWorkflowActive(workflow)
await commandStore.execute('Comfy.SaveWorkflow')
},
visible: isRoot,
prependSeparator: true
})
addItem({
label: t('menuLabels.Save As'),
icon: 'pi pi-save',
command: async () => {
await ensureWorkflowActive(workflow)
await commandStore.execute('Comfy.SaveWorkflowAs')
},
visible: isRoot
})
addItem({
label: t('menuLabels.Export'),
icon: 'pi pi-download',
command: async () => {
await ensureWorkflowActive(workflow)
await commandStore.execute('Comfy.ExportWorkflow')
},
isRoot
)
visible: isRoot,
prependSeparator: true
})
addItem(
t('menuLabels.Export (API)'),
'pi pi-download',
async () => {
addItem({
label: t('menuLabels.Export (API)'),
icon: 'pi pi-download',
command: async () => {
await ensureWorkflowActive(workflow)
await commandStore.execute('Comfy.ExportWorkflowAPI')
},
isRoot
)
visible: isRoot
})
addItem(
t('breadcrumbsMenu.clearWorkflow'),
'pi pi-trash',
async () => {
addItem({
label: isLinearMode
? t('breadcrumbsMenu.exitAppMode')
: t('breadcrumbsMenu.enterAppMode'),
icon: isLinearMode
? 'icon-[comfy--workflow]'
: 'icon-[lucide--panels-top-left]',
command: async () => {
await commandStore.execute('Comfy.ToggleLinear', {
metadata: { source: 'breadcrumb_menu' }
})
},
visible: showAppModeItems,
prependSeparator: true,
isNew: !isLinearMode
})
addItem({
label: t('breadcrumbsMenu.clearWorkflow'),
icon: 'pi pi-trash',
command: async () => {
await ensureWorkflowActive(workflow)
await commandStore.execute('Comfy.ClearWorkflow')
},
true,
false,
true
)
prependSeparator: true
})
addItem(
t('subgraphStore.publish'),
'pi pi-upload',
async () => {
addItem({
label: t('subgraphStore.publish'),
icon: 'pi pi-upload',
command: async () => {
if (workflow) {
await workflowService.saveWorkflowAs(workflow)
}
},
isRoot && isBlueprint,
false,
true
)
visible: isRoot && isBlueprint,
prependSeparator: true
})
addItem(
isBlueprint
addItem({
label: isBlueprint
? t('breadcrumbsMenu.deleteBlueprint')
: t('breadcrumbsMenu.deleteWorkflow'),
'pi pi-times',
async () => {
icon: 'pi pi-times',
command: async () => {
if (workflow) {
await workflowService.deleteWorkflow(workflow)
}
},
isRoot && includeDelete,
false,
true
)
visible: isRoot && includeDelete,
prependSeparator: true
})
return items
})

View File

@@ -2503,6 +2503,9 @@
},
"breadcrumbsMenu": {
"duplicate": "Duplicate",
"enterAppMode": "Enter app mode",
"exitAppMode": "Exit app mode",
"workflowActions": "Workflow actions",
"clearWorkflow": "Clear Workflow",
"deleteWorkflow": "Delete Workflow",
"deleteBlueprint": "Delete Blueprint",

View File

@@ -26,7 +26,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
@@ -35,6 +35,7 @@ const { batchCount } = storeToRefs(useQueueSettingsStore())
const settingStore = useSettingStore()
const { isActiveSubscription } = useBillingContext()
const workflowStore = useWorkflowStore()
const appModeStore = useAppModeStore()
const props = defineProps<{
toastTo?: string | HTMLElement
@@ -146,7 +147,10 @@ async function runButtonClick(e: Event) {
defineExpose({ runButtonClick })
</script>
<template>
<div class="flex flex-col min-w-80 md:h-full">
<div
v-if="!appModeStore.isBuilderMode"
class="flex flex-col min-w-80 md:h-full"
>
<section
v-if="mobile"
data-testid="linear-run-button"

View File

@@ -0,0 +1,17 @@
import type { OverlayIconProps } from '@/components/common/OverlayIcon.vue'
export type WorkflowMenuItem = WorkflowMenuSeparator | WorkflowMenuAction
interface WorkflowMenuSeparator {
separator: true
}
export interface WorkflowMenuAction {
separator?: false
label: string
icon?: string
command?: () => void
disabled?: boolean
badge?: string
overlayIcon?: OverlayIconProps
}

View File

@@ -1,27 +1,33 @@
<script setup lang="ts">
import { breakpointsTailwind, unrefElement, useBreakpoints } from '@vueuse/core'
import {
breakpointsTailwind,
unrefElement,
useBreakpoints,
whenever
} from '@vueuse/core'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { computed, useTemplateRef } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useSettingStore } from '@/platform/settings/settingStore'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import MobileMenu from '@/renderer/extensions/linearMode/MobileMenu.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import type { ResultItemImpl } from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const { t } = useI18n()
const nodeOutputStore = useNodeOutputStore()
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
@@ -29,11 +35,16 @@ const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
const activeTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
const { menuItems } = useWorkflowActionsMenu(
() => useCommandStore().execute('Comfy.RenameWorkflow'),
{ isRoot: true }
const hasPreview = ref(false)
whenever(
() => nodeOutputStore.latestPreview[0],
() => (hasPreview.value = true)
)
const selectedItem = ref<AssetItem>()
const selectedOutput = ref<ResultItemImpl>()
const canShowPreview = ref(true)
const topLeftRef = useTemplateRef('topLeftRef')
const topRightRef = useTemplateRef('topRightRef')
const bottomLeftRef = useTemplateRef('bottomLeftRef')
@@ -55,7 +66,14 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
<MobileMenu />
<div class="flex flex-col text-muted-foreground">
<LinearPreview
:latent-preview="
canShowPreview && hasPreview
? nodeOutputStore.latestPreview[0]
: undefined
"
:run-button-click="linearWorkflowRef?.runButtonClick"
:selected-item
:selected-output
mobile
/>
</div>
@@ -98,15 +116,18 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
:size="98"
class="flex flex-col min-w-min gap-4 mx-2 px-10 pt-8 pb-4 relative text-muted-foreground outline-none"
>
<LinearPreview :run-button-click="linearWorkflowRef?.runButtonClick" />
<LinearPreview
:latent-preview="
canShowPreview && hasPreview
? nodeOutputStore.latestPreview[0]
: undefined
"
:run-button-click="linearWorkflowRef?.runButtonClick"
:selected-item
:selected-output
/>
<div ref="topLeftRef" class="absolute z-21 top-4 left-4">
<Popover :entries="menuItems" align="start">
<template #button>
<Button size="icon" variant="textonly">
<i class="icon-[lucide--menu]" />
</Button>
</template>
</Popover>
<WorkflowActionsDropdown source="app_mode_menu_selected" />
</div>
<div ref="topRightRef" class="absolute z-21 top-4 right-4" />
<div ref="bottomLeftRef" class="absolute z-20 bottom-4 left-4" />