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:
pythongosssss
2026-02-28 20:53:26 +00:00
committed by GitHub
parent ec1977131d
commit 0ab3fdc2c9
11 changed files with 209 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -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'),

View File

@@ -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'],

View File

@@ -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()),

View File

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