feat: Node Library sidebar and V2 Search dialog UI/UX updates (#9085)

## Summary

Implement 11 Figma design discrepancies for the Node Library sidebar and
V2 Node Search dialog, aligning the UI with the [Toolbox Figma
design](https://www.figma.com/design/xMFxCziXJe6Denz4dpDGTq/Toolbox?node-id=2074-21394&m=dev).

## Changes

- **What**: Sidebar: reorder tabs (All/Essentials/Blueprints), rename
Custom→Blueprints, uppercase section headers, chevron-left of folder
icon, bookmark-on-hover for node rows, filter dropdown with checkbox
items, sort labels (Categorized/A-Z) with label-left/check-right layout,
hide section headers in A-Z mode. Search dialog: expand filter chips
from 3→6, add Recents and source categories to sidebar, remove "Filter
by" label. Pull foundation V2 components from merged PR #8548.
- **Dependencies**: Depends on #8987 (V2 Node Search) and #8548
(NodeLibrarySidebarTabV2)

## Review Focus

- Filter dropdown (`filterOptions`) is UI-scaffolded but not yet wired
to filtering logic (pending V2 integration)
- "Recents" category currently returns frequency-based results as
placeholder until a usage-tracking store is implemented
- Pre-existing type errors from V2 PR dependencies not in the base
commit (SearchBoxV2, usePerTabState, TextTicker, getProviderIcon,
getLinkTypeColor, SidebarContainerKey) are expected and will resolve
when rebased onto main after parent PRs land

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9085-feat-Node-Library-sidebar-and-V2-Search-dialog-Figma-design-improvements-30f6d73d36508175bf72d716f5904476)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Yourz <crazilou@vip.qq.com>
Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2026-02-28 06:34:27 -08:00
committed by GitHub
parent a0e518aa98
commit 3f497081ee
38 changed files with 757 additions and 381 deletions

View File

@@ -29,17 +29,79 @@
v-for="option in sortingOptions"
:key="option.id"
:value="option.id"
class="flex cursor-pointer items-center justify-end gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{ $t(option.label) }}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
<span>{{ $t(option.label) }}</span>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
<DropdownMenuRoot v-if="selectedTab === 'all'">
<DropdownMenuTrigger as-child>
<button
:aria-label="$t('sideToolbar.nodeLibraryTab.filter')"
class="flex size-10 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-comfy-input hover:bg-comfy-input-hover border-none"
>
<i class="icon-[lucide--list-filter] size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
class="z-[9999] min-w-32 rounded-lg border border-border-default bg-comfy-menu-bg p-1 shadow-lg"
align="end"
:side-offset="4"
>
<DropdownMenuCheckboxItem
v-model="filterOptions.blueprints"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.blueprints')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.partnerNodes"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.partnerNodes')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.comfyNodes"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.comfyNodes')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
v-model="filterOptions.extensions"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none hover:bg-comfy-input"
>
<span class="flex-1">{{
$t('sideToolbar.nodeLibraryTab.filterOptions.extensions')
}}</span>
<DropdownMenuItemIndicator class="w-4">
<i class="icon-[lucide--check] size-4" />
</DropdownMenuItemIndicator>
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<Separator decorative class="border border-dashed border-comfy-input" />
<!-- Tab list in header (fixed) -->
@@ -52,7 +114,7 @@
:value="tab.value"
:class="
cn(
'flex-1 text-center select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'select-none border-none outline-none px-3 py-2 rounded-lg cursor-pointer',
'text-sm text-foreground transition-colors',
selectedTab === tab.value
? 'bg-comfy-input font-bold'
@@ -75,6 +137,7 @@
"
v-model:expanded-keys="expandedKeys"
:root="renderedEssentialRoot"
:flat-nodes="essentialFlatNodes"
@node-click="handleNodeClick"
/>
<AllNodesPanel
@@ -82,12 +145,13 @@
v-model:expanded-keys="expandedKeys"
:sections="renderedSections"
:fill-node-info="fillNodeInfo"
:sort-order="sortOrder"
@node-click="handleNodeClick"
/>
<CustomNodesPanel
v-if="selectedTab === 'custom'"
<BlueprintsPanel
v-if="selectedTab === 'blueprints'"
v-model:expanded-keys="expandedKeys"
:sections="renderedCustomSections"
:sections="renderedBlueprintsSections"
@node-click="handleNodeClick"
/>
</TabsRoot>
@@ -99,6 +163,7 @@
import { cn } from '@/utils/tailwindUtil'
import { useLocalStorage } from '@vueuse/core'
import {
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItemIndicator,
DropdownMenuPortal,
@@ -125,17 +190,23 @@ import {
nodeOrganizationService
} from '@/services/nodeOrganizationService'
import { getProviderIcon } from '@/utils/categoryUtil'
import { sortedTree } from '@/utils/treeUtil'
import { flattenTree, sortedTree, unwrapTreeRoot } from '@/utils/treeUtil'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { SortingStrategyId, TabId } from '@/types/nodeOrganizationTypes'
import { buildNodeDefTree, useNodeDefStore } from '@/stores/nodeDefStore'
import type {
NodeCategoryId,
NodeSection,
SortingStrategyId,
TabId
} from '@/types/nodeOrganizationTypes'
import type {
NodeLibrarySection,
RenderedTreeExplorerNode,
TreeNode
} from '@/types/treeExplorerTypes'
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
import CustomNodesPanel from './nodeLibrary/CustomNodesPanel.vue'
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
@@ -161,7 +232,7 @@ const sortOrderByTab = useLocalStorage<Record<TabId, SortingStrategyId>>(
{
essentials: DEFAULT_SORTING_ID,
all: DEFAULT_SORTING_ID,
custom: 'alphabetical'
blueprints: 'alphabetical'
}
)
const sortOrder = usePerTabState(selectedTab, sortOrderByTab)
@@ -173,14 +244,21 @@ const sortingOptions = computed(() =>
}))
)
const filterOptions = ref<Record<NodeCategoryId, boolean>>({
blueprints: true,
partnerNodes: true,
comfyNodes: true,
extensions: true
})
const { t } = useI18n()
const searchBoxRef = ref()
const searchBoxRef = ref<InstanceType<typeof SearchBox> | null>(null)
const searchQuery = ref('')
const expandedKeysByTab = ref<Record<TabId, string[]>>({
essentials: [],
all: [],
custom: []
blueprints: []
})
const expandedKeys = usePerTabState(selectedTab, expandedKeysByTab)
@@ -213,8 +291,8 @@ const sections = computed(() => {
function getFolderIcon(node: TreeNode): string {
const firstLeaf = findFirstLeaf(node)
if (
firstLeaf?.key?.startsWith('root/api node') &&
firstLeaf.key.replace(`${node.key}/`, '') === firstLeaf.label
firstLeaf?.data?.api_node &&
firstLeaf.key?.replace(`${node.key}/`, '') === firstLeaf.label
) {
return getProviderIcon(node.label ?? '')
}
@@ -264,12 +342,33 @@ function applySorting(tree: TreeNode): TreeNode {
return tree
}
const renderedSections = computed(() => {
return sections.value.map((section) => ({
function renderSections(
nodeSections: NodeSection[],
filter?: (section: NodeSection) => boolean
): NodeLibrarySection<ComfyNodeDefImpl>[] {
const filtered = filter ? nodeSections.filter(filter) : nodeSections
if (sortOrder.value === 'alphabetical') {
const allNodes = filtered.flatMap((section) =>
flattenTree<ComfyNodeDefImpl>(section.tree)
)
const mergedTree = unwrapTreeRoot(buildNodeDefTree(allNodes))
return [{ root: fillNodeInfo(applySorting(mergedTree)) }]
}
return filtered.map((section) => ({
category: section.category,
title: section.title,
root: fillNodeInfo(applySorting(section.tree))
}))
})
}
const renderedSections = computed(() =>
renderSections(
sections.value,
(section) => !section.category || filterOptions.value[section.category]
)
)
const essentialSections = computed(() => {
if (selectedTab.value !== 'essentials') return []
@@ -286,18 +385,32 @@ const renderedEssentialRoot = computed(() => {
: fillNodeInfo({ key: 'root', label: '', children: [] })
})
const customSections = computed(() => {
if (selectedTab.value !== 'custom') return []
return nodeOrganizationService.organizeNodesByTab(activeNodes.value, 'custom')
function flattenRenderedLeaves(
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
): RenderedTreeExplorerNode<ComfyNodeDefImpl>[] {
if (node.type === 'node') return [node]
return node.children?.flatMap(flattenRenderedLeaves) ?? []
}
const essentialFlatNodes = computed(() => {
if (sortOrder.value !== 'alphabetical') return []
return flattenRenderedLeaves(renderedEssentialRoot.value).sort((a, b) =>
(a.label ?? '').localeCompare(b.label ?? '')
)
})
const renderedCustomSections = computed(() => {
return customSections.value.map((section) => ({
title: section.title,
root: fillNodeInfo(applySorting(section.tree))
}))
const blueprintsSections = computed(() => {
if (selectedTab.value !== 'blueprints') return []
return nodeOrganizationService.organizeNodesByTab(
activeNodes.value,
'blueprints'
)
})
const renderedBlueprintsSections = computed(() =>
renderSections(blueprintsSections.value)
)
function collectFolderKeys(node: TreeNode): string[] {
if (node.leaf) return []
const keys = [node.key]
@@ -334,8 +447,8 @@ async function handleSearch() {
for (const section of essentialSections.value) {
allKeys.push(...collectFolderKeys(section.tree))
}
} else if (selectedTab.value === 'custom') {
for (const section of customSections.value) {
} else if (selectedTab.value === 'blueprints') {
for (const section of blueprintsSections.value) {
allKeys.push(...collectFolderKeys(section.tree))
}
} else {
@@ -347,19 +460,18 @@ async function handleSearch() {
}
const tabs = computed(() => {
const baseTabs: Array<{ value: TabId; label: string }> = [
const allTabs: Array<{ value: TabId; label: string }> = [
{ value: 'all', label: t('sideToolbar.nodeLibraryTab.allNodes') },
{ value: 'custom', label: t('sideToolbar.nodeLibraryTab.custom') }
{
value: 'essentials' as TabId,
label: t('sideToolbar.nodeLibraryTab.essentials')
},
{
value: 'blueprints',
label: t('sideToolbar.nodeLibraryTab.blueprints')
}
]
return flags.nodeLibraryEssentialsEnabled
? [
{
value: 'essentials' as TabId,
label: t('sideToolbar.nodeLibraryTab.essentials')
},
...baseTabs
]
: baseTabs
return flags.nodeLibraryEssentialsEnabled ? allTabs : [allTabs[0], allTabs[2]]
})
onMounted(() => {