mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-05 05:00:03 +00:00
Add indicator circle when new unseen menu items are available (#9220)
## Summary Adds a little indicator circle when new workflow menu items are added that the user has not seen ## Changes - **What**: Adds a hidden setting to track menu items flagged as new that have been seen ## Screenshots (if applicable) <img width="164" height="120" alt="image" src="https://github.com/user-attachments/assets/ac36673d-fbf1-42ff-9a9e-1371eb96115b" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9220-Add-indicator-circle-when-new-unseen-menu-items-are-available-3126d73d3650819cb8cde854d6b6510b) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -43,7 +43,7 @@ function openTemplates() {
|
||||
<template>
|
||||
<div class="flex flex-col gap-2 pointer-events-auto">
|
||||
<WorkflowActionsDropdown source="app_mode_toolbar">
|
||||
<template #button>
|
||||
<template #button="{ hasUnseenItems }">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.labels.menu'),
|
||||
@@ -52,10 +52,15 @@ function openTemplates() {
|
||||
variant="secondary"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.labels.menu')"
|
||||
class="h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
class="relative h-10 rounded-lg pl-3 pr-2 gap-1 data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
<span
|
||||
v-if="hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
</template>
|
||||
</WorkflowActionsDropdown>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
data-testid="subgraph-breadcrumb"
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center"
|
||||
class="subgraph-breadcrumb flex w-auto drop-shadow-(--interface-panel-drop-shadow) items-center -mt-4 pt-4"
|
||||
:class="{
|
||||
'subgraph-breadcrumb-collapse': collapseTabs,
|
||||
'subgraph-breadcrumb-overflow': overflowingTabs
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
|
||||
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
@@ -27,8 +28,13 @@ const { menuItems } = useWorkflowActionsMenu(
|
||||
{ isRoot: true }
|
||||
)
|
||||
|
||||
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
|
||||
() => menuItems.value
|
||||
)
|
||||
|
||||
function handleOpen(open: boolean) {
|
||||
if (open) {
|
||||
markAsSeen()
|
||||
useTelemetry()?.trackUiButtonClicked({
|
||||
button_id: source
|
||||
})
|
||||
@@ -39,7 +45,7 @@ function handleOpen(open: boolean) {
|
||||
<template>
|
||||
<DropdownMenuRoot @update:open="handleOpen">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<slot name="button" :has-unseen-items="hasUnseenItems">
|
||||
<Button
|
||||
v-tooltip="{
|
||||
value: t('breadcrumbsMenu.workflowActions'),
|
||||
@@ -49,7 +55,7 @@ function handleOpen(open: boolean) {
|
||||
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"
|
||||
class="relative 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"
|
||||
@@ -60,6 +66,11 @@ function handleOpen(open: boolean) {
|
||||
"
|
||||
/>
|
||||
<i class="icon-[lucide--chevron-down] size-4 text-muted-foreground" />
|
||||
<span
|
||||
v-if="hasUnseenItems"
|
||||
aria-hidden="true"
|
||||
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
|
||||
/>
|
||||
</Button>
|
||||
</slot>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -17,7 +17,7 @@ function createWrapper(items: WorkflowMenuItem[]) {
|
||||
describe('WorkflowActionsList', () => {
|
||||
it('renders action items with label and icon', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Save', icon: 'pi pi-save', command: vi.fn() }
|
||||
{ id: 'save', label: 'Save', icon: 'pi pi-save', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
@@ -28,9 +28,9 @@ describe('WorkflowActionsList', () => {
|
||||
|
||||
it('renders separator items', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Before', icon: 'pi pi-a', command: vi.fn() },
|
||||
{ id: 'before', label: 'Before', icon: 'pi pi-a', command: vi.fn() },
|
||||
{ separator: true },
|
||||
{ label: 'After', icon: 'pi pi-b', command: vi.fn() }
|
||||
{ id: 'after', label: 'After', icon: 'pi pi-b', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
@@ -44,7 +44,7 @@ describe('WorkflowActionsList', () => {
|
||||
it('dispatches command on select', async () => {
|
||||
const command = vi.fn()
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ label: 'Action', icon: 'pi pi-play', command }
|
||||
{ id: 'action', label: 'Action', icon: 'pi pi-play', command }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
@@ -57,6 +57,7 @@ describe('WorkflowActionsList', () => {
|
||||
it('renders badge when present', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{
|
||||
id: 'new-feature',
|
||||
label: 'New Feature',
|
||||
icon: 'pi pi-star',
|
||||
command: vi.fn(),
|
||||
@@ -71,7 +72,7 @@ describe('WorkflowActionsList', () => {
|
||||
|
||||
it('does not render badge when absent', () => {
|
||||
const items: WorkflowMenuAction[] = [
|
||||
{ label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
|
||||
{ id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
|
||||
@@ -198,11 +198,13 @@ const contextMenuItems = computed<WorkflowMenuItem[]>(() => [
|
||||
...baseMenuItems.value,
|
||||
{ separator: true },
|
||||
{
|
||||
id: 'close-tab',
|
||||
label: t('tabMenu.closeTab'),
|
||||
icon: 'pi pi-times',
|
||||
command: () => onCloseWorkflow(props.workflowOption)
|
||||
},
|
||||
{
|
||||
id: 'close-tabs-to-left',
|
||||
label: t('tabMenu.closeTabsToLeft'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
@@ -215,6 +217,7 @@ const contextMenuItems = computed<WorkflowMenuItem[]>(() => [
|
||||
disabled: props.isFirst
|
||||
},
|
||||
{
|
||||
id: 'close-tabs-to-right',
|
||||
label: t('tabMenu.closeTabsToRight'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
@@ -227,6 +230,7 @@ const contextMenuItems = computed<WorkflowMenuItem[]>(() => [
|
||||
disabled: props.isLast
|
||||
},
|
||||
{
|
||||
id: 'close-other-tabs',
|
||||
label: t('tabMenu.closeOtherTabs'),
|
||||
overlayIcon: {
|
||||
mainIcon: 'pi pi-times',
|
||||
|
||||
111
src/composables/useNewMenuItemIndicator.test.ts
Normal file
111
src/composables/useNewMenuItemIndicator.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
|
||||
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
|
||||
|
||||
const mockSettingStore = vi.hoisted(() => ({
|
||||
get: vi.fn((): string[] => []),
|
||||
set: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => mockSettingStore)
|
||||
}))
|
||||
|
||||
function createItems(...ids: string[]): WorkflowMenuItem[] {
|
||||
return ids.map((id) => ({
|
||||
id,
|
||||
label: `Label for ${id}`,
|
||||
icon: 'pi pi-test',
|
||||
command: vi.fn(),
|
||||
isNew: true,
|
||||
badge: 'BETA'
|
||||
}))
|
||||
}
|
||||
|
||||
describe('useNewMenuItemIndicator', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
mockSettingStore.get.mockReturnValue([])
|
||||
})
|
||||
|
||||
it('reports unseen items when no items have been seen', () => {
|
||||
const items = createItems('feature-a')
|
||||
const { hasUnseenItems } = useNewMenuItemIndicator(() => items)
|
||||
|
||||
expect(hasUnseenItems.value).toBe(true)
|
||||
})
|
||||
|
||||
it('reports no unseen items when all new items are already seen', () => {
|
||||
mockSettingStore.get.mockReturnValue(['feature-a'])
|
||||
const items = createItems('feature-a')
|
||||
const { hasUnseenItems } = useNewMenuItemIndicator(() => items)
|
||||
|
||||
expect(hasUnseenItems.value).toBe(false)
|
||||
})
|
||||
|
||||
it('reports unseen when some new items are not yet seen', () => {
|
||||
mockSettingStore.get.mockReturnValue(['feature-a'])
|
||||
const items = createItems('feature-a', 'feature-b')
|
||||
const { hasUnseenItems } = useNewMenuItemIndicator(() => items)
|
||||
|
||||
expect(hasUnseenItems.value).toBe(true)
|
||||
})
|
||||
|
||||
it('reports no unseen items when menu has no isNew items', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ id: 'regular', label: 'Regular', icon: 'pi pi-test', command: vi.fn() }
|
||||
]
|
||||
const { hasUnseenItems } = useNewMenuItemIndicator(() => items)
|
||||
|
||||
expect(hasUnseenItems.value).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores separators', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ separator: true },
|
||||
...createItems('feature-a')
|
||||
]
|
||||
const { hasUnseenItems } = useNewMenuItemIndicator(() => items)
|
||||
|
||||
expect(hasUnseenItems.value).toBe(true)
|
||||
})
|
||||
|
||||
it('markAsSeen persists current new item ids', () => {
|
||||
const items = createItems('feature-a', 'feature-b')
|
||||
const { markAsSeen } = useNewMenuItemIndicator(() => items)
|
||||
|
||||
markAsSeen()
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.WorkflowActions.SeenItems',
|
||||
['feature-a', 'feature-b']
|
||||
)
|
||||
})
|
||||
|
||||
it('markAsSeen replaces stale entries with current new items', () => {
|
||||
mockSettingStore.get.mockReturnValue(['old-feature', 'feature-a'])
|
||||
const items = createItems('feature-a')
|
||||
const { markAsSeen } = useNewMenuItemIndicator(() => items)
|
||||
|
||||
markAsSeen()
|
||||
|
||||
expect(mockSettingStore.set).toHaveBeenCalledWith(
|
||||
'Comfy.WorkflowActions.SeenItems',
|
||||
['feature-a']
|
||||
)
|
||||
})
|
||||
|
||||
it('markAsSeen does nothing when there are no new items', () => {
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ id: 'regular', label: 'Regular', icon: 'pi pi-test', command: vi.fn() }
|
||||
]
|
||||
const { markAsSeen } = useNewMenuItemIndicator(() => items)
|
||||
|
||||
markAsSeen()
|
||||
|
||||
expect(mockSettingStore.set).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
41
src/composables/useNewMenuItemIndicator.ts
Normal file
41
src/composables/useNewMenuItemIndicator.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type {
|
||||
WorkflowMenuAction,
|
||||
WorkflowMenuItem
|
||||
} from '@/types/workflowMenuItem'
|
||||
|
||||
function getNewItemIds(items: WorkflowMenuItem[]): string[] {
|
||||
return items
|
||||
.filter((i): i is WorkflowMenuAction => !('separator' in i && i.separator))
|
||||
.filter((i) => i.isNew)
|
||||
.map((i) => i.id)
|
||||
}
|
||||
|
||||
export function useNewMenuItemIndicator(
|
||||
menuItems: MaybeRefOrGetter<WorkflowMenuItem[]>
|
||||
) {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const newItemIds = computed(() => getNewItemIds(toValue(menuItems)))
|
||||
|
||||
const seenItems = computed<string[]>(
|
||||
() => settingStore.get('Comfy.WorkflowActions.SeenItems') ?? []
|
||||
)
|
||||
|
||||
const hasUnseenItems = computed(() => {
|
||||
const seen = new Set(seenItems.value)
|
||||
return newItemIds.value.some((id) => !seen.has(id))
|
||||
})
|
||||
|
||||
function markAsSeen() {
|
||||
if (!newItemIds.value.length) return
|
||||
void settingStore.set('Comfy.WorkflowActions.SeenItems', [
|
||||
...newItemIds.value
|
||||
])
|
||||
}
|
||||
|
||||
return { hasUnseenItems, markAsSeen }
|
||||
}
|
||||
@@ -28,6 +28,7 @@ interface WorkflowActionsMenuOptions {
|
||||
}
|
||||
|
||||
interface AddItemOptions {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
command: () => void
|
||||
@@ -71,6 +72,7 @@ export function useWorkflowActionsMenu(
|
||||
const items: WorkflowMenuItem[] = []
|
||||
|
||||
const addItem = ({
|
||||
id,
|
||||
label,
|
||||
icon,
|
||||
command,
|
||||
@@ -81,9 +83,10 @@ export function useWorkflowActionsMenu(
|
||||
}: AddItemOptions) => {
|
||||
if (!visible) return
|
||||
if (prependSeparator) items.push({ separator: true })
|
||||
const item: WorkflowMenuAction = { label, icon, command, disabled }
|
||||
const item: WorkflowMenuAction = { id, label, icon, command, disabled }
|
||||
if (isNew) {
|
||||
item.badge = t('contextMenu.new')
|
||||
item.badge = t('g.experimental')
|
||||
item.isNew = true
|
||||
}
|
||||
items.push(item)
|
||||
}
|
||||
@@ -94,6 +97,7 @@ export function useWorkflowActionsMenu(
|
||||
const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '')
|
||||
|
||||
addItem({
|
||||
id: 'rename',
|
||||
label: t('g.rename'),
|
||||
icon: 'pi pi-pencil',
|
||||
command: async () => {
|
||||
@@ -104,6 +108,7 @@ export function useWorkflowActionsMenu(
|
||||
})
|
||||
|
||||
addItem({
|
||||
id: 'duplicate',
|
||||
label: t('breadcrumbsMenu.duplicate'),
|
||||
icon: 'pi pi-copy',
|
||||
command: async () => {
|
||||
@@ -115,6 +120,7 @@ export function useWorkflowActionsMenu(
|
||||
})
|
||||
|
||||
addItem({
|
||||
id: 'toggle-bookmark',
|
||||
label: isBookmarked
|
||||
? t('tabMenu.removeFromBookmarks')
|
||||
: t('tabMenu.addToBookmarks'),
|
||||
@@ -129,6 +135,7 @@ export function useWorkflowActionsMenu(
|
||||
})
|
||||
|
||||
addItem({
|
||||
id: 'save',
|
||||
label: t('menuLabels.Save'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
@@ -140,6 +147,7 @@ export function useWorkflowActionsMenu(
|
||||
})
|
||||
|
||||
addItem({
|
||||
id: 'save-as',
|
||||
label: t('menuLabels.Save As'),
|
||||
icon: 'pi pi-save',
|
||||
command: async () => {
|
||||
@@ -150,6 +158,7 @@ export function useWorkflowActionsMenu(
|
||||
})
|
||||
|
||||
addItem({
|
||||
id: 'export',
|
||||
label: t('menuLabels.Export'),
|
||||
icon: 'pi pi-download',
|
||||
command: async () => {
|
||||
@@ -161,6 +170,7 @@ export function useWorkflowActionsMenu(
|
||||
})
|
||||
|
||||
addItem({
|
||||
id: 'export-api',
|
||||
label: t('menuLabels.Export (API)'),
|
||||
icon: 'pi pi-download',
|
||||
command: async () => {
|
||||
@@ -171,6 +181,7 @@ export function useWorkflowActionsMenu(
|
||||
})
|
||||
|
||||
addItem({
|
||||
id: 'toggle-app-mode',
|
||||
label: isLinearMode
|
||||
? t('breadcrumbsMenu.exitAppMode')
|
||||
: t('breadcrumbsMenu.enterAppMode'),
|
||||
@@ -188,6 +199,7 @@ export function useWorkflowActionsMenu(
|
||||
})
|
||||
|
||||
addItem({
|
||||
id: 'clear-workflow',
|
||||
label: t('breadcrumbsMenu.clearWorkflow'),
|
||||
icon: 'pi pi-trash',
|
||||
command: async () => {
|
||||
@@ -198,6 +210,7 @@ export function useWorkflowActionsMenu(
|
||||
})
|
||||
|
||||
addItem({
|
||||
id: 'publish',
|
||||
label: t('subgraphStore.publish'),
|
||||
icon: 'pi pi-upload',
|
||||
command: async () => {
|
||||
@@ -210,6 +223,7 @@ export function useWorkflowActionsMenu(
|
||||
})
|
||||
|
||||
addItem({
|
||||
id: 'delete',
|
||||
label: isBlueprint
|
||||
? t('breadcrumbsMenu.deleteBlueprint')
|
||||
: t('breadcrumbsMenu.deleteWorkflow'),
|
||||
|
||||
@@ -856,6 +856,13 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
defaultValue: false,
|
||||
versionAdded: '1.37.0'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.WorkflowActions.SeenItems',
|
||||
name: 'Seen new workflow action items',
|
||||
type: 'hidden',
|
||||
defaultValue: [] as string[],
|
||||
versionAdded: '1.41.5'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Execution.PreviewMethod',
|
||||
category: ['Comfy', 'Execution', 'PreviewMethod'],
|
||||
|
||||
@@ -373,6 +373,7 @@ const zSettings = z.object({
|
||||
'Comfy.QueueButton.BatchCountLimit': z.number(),
|
||||
'Comfy.Queue.MaxHistoryItems': z.number(),
|
||||
'Comfy.Queue.History.Expanded': z.boolean(),
|
||||
'Comfy.WorkflowActions.SeenItems': z.array(z.string()),
|
||||
'Comfy.Keybinding.UnsetBindings': z.array(zKeybinding),
|
||||
'Comfy.Keybinding.NewBindings': z.array(zKeybinding),
|
||||
'Comfy.Extension.Disabled': z.array(z.string()),
|
||||
|
||||
@@ -8,10 +8,12 @@ interface WorkflowMenuSeparator {
|
||||
|
||||
export interface WorkflowMenuAction {
|
||||
separator?: false
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
command?: () => void
|
||||
disabled?: boolean
|
||||
badge?: string
|
||||
isNew?: boolean
|
||||
overlayIcon?: OverlayIconProps
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user