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

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
})