feat: App mode enter builder menu item (#9341)

## Summary

Adds enter builder menu item for easier access to app builder. 
Fixes issues with seen item tracking

## Changes

- **What**: 
- add enter builder menu item
- change non visible items to still be returned as part of the array, so
they are not incorrectly removed from the seen-items tracking
- split toggle-app-mode into two stable items

## Screenshots (if applicable)

<img width="309" height="526" alt="image"
src="https://github.com/user-attachments/assets/69affc2c-34ab-45eb-b47b-efacb8a20b99"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9341-feat-App-mode-enter-builder-menu-item-3176d73d365081a9a7e7cf1a1986354f)
by [Unito](https://www.unito.io)
This commit is contained in:
pythongosssss
2026-03-03 16:35:47 +00:00
committed by GitHub
parent d360b2218f
commit b18a0713db
8 changed files with 172 additions and 26 deletions

View File

@@ -70,6 +70,24 @@ describe('WorkflowActionsList', () => {
expect(wrapper.text()).toContain('NEW')
})
it('does not render items with visible set to false', () => {
const items: WorkflowMenuItem[] = [
{
id: 'hidden',
label: 'Hidden Item',
icon: 'pi pi-eye-slash',
command: vi.fn(),
visible: false
},
{ id: 'shown', label: 'Shown Item', icon: 'pi pi-eye', command: vi.fn() }
]
const wrapper = createWrapper(items)
expect(wrapper.text()).not.toContain('Hidden Item')
expect(wrapper.text()).toContain('Shown Item')
})
it('does not render badge when absent', () => {
const items: WorkflowMenuAction[] = [
{ id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() }

View File

@@ -26,7 +26,7 @@ const {
/>
<component
:is="itemComponent"
v-else
v-else-if="item.visible !== false"
:disabled="item.disabled"
:class="
cn(

View File

@@ -98,6 +98,80 @@ describe('useNewMenuItemIndicator', () => {
)
})
it('does not count hidden items as unseen', () => {
const items: WorkflowMenuItem[] = [
{
id: 'hidden-feature',
label: 'Hidden',
icon: 'pi pi-test',
command: vi.fn(),
isNew: true,
badge: 'BETA',
visible: false
}
]
const { hasUnseenItems } = useNewMenuItemIndicator(() => items)
expect(hasUnseenItems.value).toBe(false)
})
it('markAsSeen does not include never-seen hidden items', () => {
const items: WorkflowMenuItem[] = [
...createItems('feature-a'),
{
id: 'hidden-feature',
label: 'Hidden',
icon: 'pi pi-test',
command: vi.fn(),
isNew: true,
badge: 'BETA',
visible: false
}
]
const { markAsSeen } = useNewMenuItemIndicator(() => items)
markAsSeen()
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.WorkflowActions.SeenItems',
['feature-a']
)
})
it('markAsSeen retains previously-seen hidden items', () => {
mockSettingStore.get.mockReturnValue(['hidden-feature'])
const items: WorkflowMenuItem[] = [
...createItems('feature-a'),
{
id: 'hidden-feature',
label: 'Hidden',
icon: 'pi pi-test',
command: vi.fn(),
isNew: true,
badge: 'BETA',
visible: false
}
]
const { markAsSeen } = useNewMenuItemIndicator(() => items)
markAsSeen()
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.WorkflowActions.SeenItems',
['feature-a', 'hidden-feature']
)
})
it('markAsSeen skips write when stored list already matches', () => {
mockSettingStore.get.mockReturnValue(['feature-a', 'feature-b'])
const items = createItems('feature-a', 'feature-b')
const { markAsSeen } = useNewMenuItemIndicator(() => items)
markAsSeen()
expect(mockSettingStore.set).not.toHaveBeenCalled()
})
it('markAsSeen does nothing when there are no new items', () => {
const items: WorkflowMenuItem[] = [
{ id: 'regular', label: 'Regular', icon: 'pi pi-test', command: vi.fn() }

View File

@@ -7,11 +7,10 @@ import type {
WorkflowMenuItem
} from '@/types/workflowMenuItem'
function getNewItemIds(items: WorkflowMenuItem[]): string[] {
function getNewActions(items: WorkflowMenuItem[]): WorkflowMenuAction[] {
return items
.filter((i): i is WorkflowMenuAction => !('separator' in i && i.separator))
.filter((i) => i.isNew)
.map((i) => i.id)
}
export function useNewMenuItemIndicator(
@@ -19,7 +18,7 @@ export function useNewMenuItemIndicator(
) {
const settingStore = useSettingStore()
const newItemIds = computed(() => getNewItemIds(toValue(menuItems)))
const newActions = computed(() => getNewActions(toValue(menuItems)))
const seenItems = computed<string[]>(
() => settingStore.get('Comfy.WorkflowActions.SeenItems') ?? []
@@ -27,14 +26,28 @@ export function useNewMenuItemIndicator(
const hasUnseenItems = computed(() => {
const seen = new Set(seenItems.value)
return newItemIds.value.some((id) => !seen.has(id))
return newActions.value
.filter((i) => i.visible !== false)
.some((i) => !seen.has(i.id))
})
function markAsSeen() {
if (!newItemIds.value.length) return
void settingStore.set('Comfy.WorkflowActions.SeenItems', [
...newItemIds.value
])
const actions = newActions.value
if (!actions.length) return
const seen = new Set(seenItems.value)
const visibleIds = actions
.filter((i) => i.visible !== false)
.map((i) => i.id)
const retainedIds = actions
.filter((i) => i.visible === false && seen.has(i.id))
.map((i) => i.id)
const nextSeen = [...visibleIds, ...retainedIds]
if (nextSeen.length === seen.size && nextSeen.every((id) => seen.has(id)))
return
void settingStore.set('Comfy.WorkflowActions.SeenItems', nextSeen)
}
return { hasUnseenItems, markAsSeen }

View File

@@ -44,6 +44,10 @@ const mockCanvasStore = vi.hoisted(() => ({
linearMode: false
}))
const mockAppModeStore = vi.hoisted(() => ({
enterBuilder: vi.fn()
}))
const mockFeatureFlags = vi.hoisted(() => ({
flags: { linearToggleEnabled: false }
}))
@@ -73,6 +77,10 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => mockCanvasStore)
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: vi.fn(() => mockAppModeStore)
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: vi.fn(() => mockFeatureFlags)
}))
@@ -80,7 +88,9 @@ vi.mock('@/composables/useFeatureFlags', () => ({
type MenuItems = ReturnType<typeof useWorkflowActionsMenu>['menuItems']['value']
function actionItems(items: MenuItems): WorkflowMenuAction[] {
return items.filter((i): i is WorkflowMenuAction => !i.separator)
return items.filter(
(i): i is WorkflowMenuAction => !i.separator && i.visible !== false
)
}
function menuLabels(items: MenuItems) {
@@ -288,6 +298,18 @@ describe('useWorkflowActionsMenu', () => {
expect(mockBookmarkStore.toggleBookmarked).toHaveBeenCalledWith('test.json')
})
it('enter builder mode calls enterBuilder', async () => {
mockFeatureFlags.flags.linearToggleEnabled = true
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
await findItem(
menuItems.value,
'breadcrumbsMenu.enterBuilderMode'
).command?.()
expect(mockAppModeStore.enterBuilder).toHaveBeenCalled()
})
it('app mode toggle executes Comfy.ToggleLinear', async () => {
mockFeatureFlags.flags.linearToggleEnabled = true

View File

@@ -13,6 +13,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useAppModeStore } from '@/stores/appModeStore'
import type {
WorkflowMenuAction,
WorkflowMenuItem
@@ -52,6 +53,7 @@ export function useWorkflowActionsMenu(
const menuItemStore = useMenuItemStore()
const canvasStore = useCanvasStore()
const { flags } = useFeatureFlags()
const { enterBuilder } = useAppModeStore()
const targetWorkflow = computed(
() => workflow?.value ?? workflowStore.activeWorkflow
@@ -81,9 +83,9 @@ export function useWorkflowActionsMenu(
prependSeparator = false,
isNew = false
}: AddItemOptions) => {
if (!visible) return
if (prependSeparator) items.push({ separator: true })
if (prependSeparator && visible) items.push({ separator: true })
const item: WorkflowMenuAction = { id, label, icon, command, disabled }
if (!visible) item.visible = false
if (isNew) {
item.badge = t('g.experimental')
item.isNew = true
@@ -96,6 +98,11 @@ export function useWorkflowActionsMenu(
isRoot && (menuItemStore.hasSeenLinear || flags.linearToggleEnabled)
const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '')
const toggleLinear = async () => {
await commandStore.execute('Comfy.ToggleLinear', {
metadata: { source: 'breadcrumb_menu' }
})
}
addItem({
id: 'rename',
label: t('g.rename'),
@@ -181,21 +188,31 @@ export function useWorkflowActionsMenu(
})
addItem({
id: 'toggle-app-mode',
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,
id: 'enter-app-mode',
label: t('breadcrumbsMenu.enterAppMode'),
icon: 'icon-[lucide--panels-top-left]',
command: toggleLinear,
visible: showAppModeItems && !isLinearMode,
prependSeparator: true,
isNew: !isLinearMode
isNew: true
})
addItem({
id: 'exit-app-mode',
label: t('breadcrumbsMenu.exitAppMode'),
icon: 'icon-[comfy--workflow]',
command: toggleLinear,
visible: isLinearMode,
prependSeparator: true
})
addItem({
id: 'enter-builder-mode',
label: t('breadcrumbsMenu.enterBuilderMode'),
icon: 'icon-[lucide--hammer]',
command: () => enterBuilder(),
visible: showAppModeItems,
isNew: true
})
addItem({

View File

@@ -2589,6 +2589,7 @@
"duplicate": "Duplicate",
"enterAppMode": "Enter app mode",
"exitAppMode": "Exit app mode",
"enterBuilderMode": "Enter app builder",
"workflowActions": "Workflow actions",
"clearWorkflow": "Clear Workflow",
"deleteWorkflow": "Delete Workflow",

View File

@@ -8,6 +8,7 @@ interface WorkflowMenuSeparator {
export interface WorkflowMenuAction {
separator?: false
visible?: boolean
id: string
label: string
icon?: string