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,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>