diff --git a/.stylelintrc.json b/.stylelintrc.json
index 71a1311ae5..26ce840f1e 100644
--- a/.stylelintrc.json
+++ b/.stylelintrc.json
@@ -52,7 +52,8 @@
"reference",
"plugin",
"custom-variant",
- "utility"
+ "utility",
+ "source"
]
}
],
diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts
index 6439071639..9bd03386b4 100644
--- a/browser_tests/fixtures/ComfyPage.ts
+++ b/browser_tests/fixtures/ComfyPage.ts
@@ -14,6 +14,7 @@ import { ComfyTemplates } from '../helpers/templates'
import { ComfyMouse } from './ComfyMouse'
import { VueNodeHelpers } from './VueNodeHelpers'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
+import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2'
import { ContextMenu } from './components/ContextMenu'
import { SettingDialog } from './components/SettingDialog'
import { BottomPanel } from './components/BottomPanel'
@@ -166,6 +167,7 @@ export class ComfyPage {
// Components
public readonly searchBox: ComfyNodeSearchBox
+ public readonly searchBoxV2: ComfyNodeSearchBoxV2
public readonly menu: ComfyMenu
public readonly actionbar: ComfyActionbar
public readonly templates: ComfyTemplates
@@ -210,6 +212,7 @@ export class ComfyPage {
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
+ this.searchBoxV2 = new ComfyNodeSearchBoxV2(page)
this.menu = new ComfyMenu(page)
this.actionbar = new ComfyActionbar(page)
this.templates = new ComfyTemplates(page)
diff --git a/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts b/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts
new file mode 100644
index 0000000000..64f6b9cebb
--- /dev/null
+++ b/browser_tests/fixtures/components/ComfyNodeSearchBoxV2.ts
@@ -0,0 +1,29 @@
+import type { Locator, Page } from '@playwright/test'
+
+import type { ComfyPage } from '../ComfyPage'
+
+export class ComfyNodeSearchBoxV2 {
+ readonly dialog: Locator
+ readonly input: Locator
+ readonly results: Locator
+ readonly filterOptions: Locator
+
+ constructor(readonly page: Page) {
+ this.dialog = page.getByRole('search')
+ this.input = this.dialog.locator('input[type="text"]')
+ this.results = this.dialog.getByTestId('result-item')
+ this.filterOptions = this.dialog.getByTestId('filter-option')
+ }
+
+ categoryButton(categoryId: string): Locator {
+ return this.dialog.getByTestId(`category-${categoryId}`)
+ }
+
+ filterBarButton(name: string): Locator {
+ return this.dialog.getByRole('button', { name })
+ }
+
+ async reload(comfyPage: ComfyPage) {
+ await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
+ }
+}
diff --git a/browser_tests/tests/dialog.spec.ts b/browser_tests/tests/dialog.spec.ts
index eed31cc354..f70a527e3d 100644
--- a/browser_tests/tests/dialog.spec.ts
+++ b/browser_tests/tests/dialog.spec.ts
@@ -37,7 +37,7 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
})
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
- await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
+ await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await comfyPage.page
diff --git a/browser_tests/tests/groupNode.spec.ts b/browser_tests/tests/groupNode.spec.ts
index 6af103c8fb..90234e110d 100644
--- a/browser_tests/tests/groupNode.spec.ts
+++ b/browser_tests/tests/groupNode.spec.ts
@@ -10,6 +10,7 @@ import type { NodeReference } from '../fixtures/utils/litegraphUtils'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
+ await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
})
test.describe('Group Node', { tag: '@node' }, () => {
diff --git a/browser_tests/tests/nodeSearchBox.spec.ts b/browser_tests/tests/nodeSearchBox.spec.ts
index 4b50ee3ca3..fc6d3ccb4f 100644
--- a/browser_tests/tests/nodeSearchBox.spec.ts
+++ b/browser_tests/tests/nodeSearchBox.spec.ts
@@ -18,7 +18,10 @@ test.describe('Node search box', { tag: '@node' }, () => {
'Comfy.LinkRelease.ActionShift',
'search box'
)
- await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeSearchBoxImpl',
+ 'v1 (legacy)'
+ )
})
test(`Can trigger on empty canvas double click`, async ({ comfyPage }) => {
@@ -45,7 +48,10 @@ test.describe('Node search box', { tag: '@node' }, () => {
await comfyPage.setup({ clearStorage: true })
// Simulate new user with 1.24.1+ installed version
await comfyPage.settings.setSetting('Comfy.InstalledVersion', '1.24.1')
- await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeSearchBoxImpl',
+ 'v1 (legacy)'
+ )
// Don't set LinkRelease settings explicitly to test versioned defaults
await comfyPage.canvasOps.disconnectEdge()
@@ -285,7 +291,10 @@ test.describe('Release context menu', { tag: '@node' }, () => {
'Comfy.LinkRelease.ActionShift',
'search box'
)
- await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeSearchBoxImpl',
+ 'v1 (legacy)'
+ )
})
test(
@@ -329,7 +338,10 @@ test.describe('Release context menu', { tag: '@node' }, () => {
await comfyPage.setup({ clearStorage: true })
// Simulate existing user with pre-1.24.1 version
await comfyPage.settings.setSetting('Comfy.InstalledVersion', '1.23.0')
- await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeSearchBoxImpl',
+ 'v1 (legacy)'
+ )
// Don't set LinkRelease settings explicitly to test versioned defaults
await comfyPage.canvasOps.disconnectEdge()
@@ -350,7 +362,10 @@ test.describe('Release context menu', { tag: '@node' }, () => {
'Comfy.LinkRelease.Action',
'context menu'
)
- await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeSearchBoxImpl',
+ 'v1 (legacy)'
+ )
await comfyPage.canvasOps.disconnectEdge()
// Context menu should appear due to explicit setting, not search box
diff --git a/browser_tests/tests/nodeSearchBoxV2.spec.ts b/browser_tests/tests/nodeSearchBoxV2.spec.ts
new file mode 100644
index 0000000000..0c91963eed
--- /dev/null
+++ b/browser_tests/tests/nodeSearchBoxV2.spec.ts
@@ -0,0 +1,149 @@
+import {
+ comfyExpect as expect,
+ comfyPageFixture as test
+} from '../fixtures/ComfyPage'
+
+test.describe('Node search box V2', { tag: '@node' }, () => {
+ test.beforeEach(async ({ comfyPage }) => {
+ await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
+ await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
+ await comfyPage.settings.setSetting(
+ 'Comfy.LinkRelease.Action',
+ 'search box'
+ )
+ await comfyPage.settings.setSetting(
+ 'Comfy.LinkRelease.ActionShift',
+ 'search box'
+ )
+ await comfyPage.searchBoxV2.reload(comfyPage)
+ })
+
+ test('Can open search and add node', async ({ comfyPage }) => {
+ const { searchBoxV2 } = comfyPage
+ const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
+
+ await comfyPage.canvasOps.doubleClick()
+ await expect(searchBoxV2.input).toBeVisible()
+
+ await searchBoxV2.input.fill('KSampler')
+ await expect(searchBoxV2.results.first()).toBeVisible()
+
+ await comfyPage.page.keyboard.press('Enter')
+ await expect(searchBoxV2.input).not.toBeVisible()
+
+ const newCount = await comfyPage.nodeOps.getGraphNodesCount()
+ expect(newCount).toBe(initialCount + 1)
+ })
+
+ test('Can add first default result with Enter', async ({ comfyPage }) => {
+ const { searchBoxV2 } = comfyPage
+ const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
+
+ await comfyPage.canvasOps.doubleClick()
+ await expect(searchBoxV2.input).toBeVisible()
+
+ // Default results should be visible without typing
+ await expect(searchBoxV2.results.first()).toBeVisible()
+
+ // Enter should add the first (selected) result
+ await comfyPage.page.keyboard.press('Enter')
+ await expect(searchBoxV2.input).not.toBeVisible()
+
+ const newCount = await comfyPage.nodeOps.getGraphNodesCount()
+ expect(newCount).toBe(initialCount + 1)
+ })
+
+ test.describe('Category navigation', () => {
+ test('Favorites shows only bookmarked nodes', async ({ comfyPage }) => {
+ const { searchBoxV2 } = comfyPage
+ await comfyPage.settings.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [
+ 'KSampler'
+ ])
+ await searchBoxV2.reload(comfyPage)
+
+ await comfyPage.canvasOps.doubleClick()
+ await expect(searchBoxV2.input).toBeVisible()
+
+ await searchBoxV2.categoryButton('favorites').click()
+
+ await expect(searchBoxV2.results).toHaveCount(1)
+ await expect(searchBoxV2.results.first()).toContainText('KSampler')
+ })
+
+ test('Category filters results to matching nodes', async ({
+ comfyPage
+ }) => {
+ const { searchBoxV2 } = comfyPage
+
+ await comfyPage.canvasOps.doubleClick()
+ await expect(searchBoxV2.input).toBeVisible()
+
+ await searchBoxV2.categoryButton('sampling').click()
+
+ await expect(searchBoxV2.results.first()).toBeVisible()
+ const count = await searchBoxV2.results.count()
+ expect(count).toBeGreaterThan(0)
+ })
+ })
+
+ test.describe('Filter workflow', () => {
+ test('Can filter by input type via filter bar', async ({ comfyPage }) => {
+ const { searchBoxV2 } = comfyPage
+
+ await comfyPage.canvasOps.doubleClick()
+ await expect(searchBoxV2.input).toBeVisible()
+
+ // Click "Input" filter chip in the filter bar
+ await searchBoxV2.filterBarButton('Input').click()
+
+ // Filter options should appear
+ await expect(searchBoxV2.filterOptions.first()).toBeVisible()
+
+ // Type to narrow and select MODEL
+ await searchBoxV2.input.fill('MODEL')
+ await searchBoxV2.filterOptions
+ .filter({ hasText: 'MODEL' })
+ .first()
+ .click()
+
+ // Filter chip should appear and results should be filtered
+ await expect(
+ searchBoxV2.dialog.getByText('Input:', { exact: false }).locator('..')
+ ).toContainText('MODEL')
+ await expect(searchBoxV2.results.first()).toBeVisible()
+ })
+ })
+
+ test.describe('Keyboard navigation', () => {
+ test('Can navigate and select with keyboard', async ({ comfyPage }) => {
+ const { searchBoxV2 } = comfyPage
+ const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
+
+ await comfyPage.canvasOps.doubleClick()
+ await expect(searchBoxV2.input).toBeVisible()
+
+ await searchBoxV2.input.fill('KSampler')
+ const results = searchBoxV2.results
+ await expect(results.first()).toBeVisible()
+
+ // First result selected by default
+ await expect(results.first()).toHaveAttribute('aria-selected', 'true')
+
+ // ArrowDown moves selection
+ await comfyPage.page.keyboard.press('ArrowDown')
+ await expect(results.nth(1)).toHaveAttribute('aria-selected', 'true')
+ await expect(results.first()).toHaveAttribute('aria-selected', 'false')
+
+ // ArrowUp moves back
+ await comfyPage.page.keyboard.press('ArrowUp')
+ await expect(results.first()).toHaveAttribute('aria-selected', 'true')
+
+ // Enter selects and adds node
+ await comfyPage.page.keyboard.press('Enter')
+ await expect(searchBoxV2.input).not.toBeVisible()
+
+ const newCount = await comfyPage.nodeOps.getGraphNodesCount()
+ expect(newCount).toBe(initialCount + 1)
+ })
+ })
+})
diff --git a/browser_tests/tests/recordAudio.spec.ts b/browser_tests/tests/recordAudio.spec.ts
index 07d9c48081..c46a5168e6 100644
--- a/browser_tests/tests/recordAudio.spec.ts
+++ b/browser_tests/tests/recordAudio.spec.ts
@@ -4,6 +4,7 @@ import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
+ await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
})
test.describe('Record Audio Node', { tag: '@screenshot' }, () => {
diff --git a/browser_tests/tests/remoteWidgets.spec.ts b/browser_tests/tests/remoteWidgets.spec.ts
index b09421bc68..e29f46ea2d 100644
--- a/browser_tests/tests/remoteWidgets.spec.ts
+++ b/browser_tests/tests/remoteWidgets.spec.ts
@@ -53,6 +53,10 @@ test.describe('Remote COMBO Widget', { tag: '@widget' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeSearchBoxImpl',
+ 'v1 (legacy)'
+ )
})
test.describe('Loading options', () => {
diff --git a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-node-collapsed-badge-chromium-linux.png b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-node-collapsed-badge-chromium-linux.png
index 97144d4827..37ceaa2a90 100644
Binary files a/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-node-collapsed-badge-chromium-linux.png and b/browser_tests/tests/rightClickMenu.spec.ts-snapshots/right-click-node-collapsed-badge-chromium-linux.png differ
diff --git a/browser_tests/tests/subgraph.spec.ts b/browser_tests/tests/subgraph.spec.ts
index d31c3bd6a8..d8010c8437 100644
--- a/browser_tests/tests/subgraph.spec.ts
+++ b/browser_tests/tests/subgraph.spec.ts
@@ -19,6 +19,10 @@ const SELECTORS = {
test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeSearchBoxImpl',
+ 'v1 (legacy)'
+ )
})
// Helper to get subgraph slot count
diff --git a/browser_tests/tests/subgraphSearchAliases.spec.ts b/browser_tests/tests/subgraphSearchAliases.spec.ts
index a17e56dfd3..ee1bbbb488 100644
--- a/browser_tests/tests/subgraphSearchAliases.spec.ts
+++ b/browser_tests/tests/subgraphSearchAliases.spec.ts
@@ -54,7 +54,10 @@ async function searchAndExpectResult(
test.describe('Subgraph Search Aliases', { tag: ['@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
- await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeSearchBoxImpl',
+ 'v1 (legacy)'
+ )
})
test('Can set search aliases on subgraph and find via search', async ({
diff --git a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts
index ab41c27858..1819edde34 100644
--- a/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts
+++ b/browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts
@@ -102,6 +102,7 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
+ await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
// await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('vueNodes/simple-triple')
await comfyPage.vueNodes.waitForNodes()
@@ -928,7 +929,10 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
'Comfy.LinkRelease.ActionShift',
'context menu'
)
- await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'default')
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeSearchBoxImpl',
+ 'v1 (legacy)'
+ )
const samplerNode = (
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
@@ -994,6 +998,10 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
'Comfy.LinkRelease.ActionShift',
'search box'
)
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeSearchBoxImpl',
+ 'v1 (legacy)'
+ )
const samplerNode = (
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
@@ -1048,6 +1056,11 @@ test.describe('Vue Node Link Interaction', { tag: '@screenshot' }, () => {
comfyPage,
comfyMouse
}) => {
+ await comfyPage.settings.setSetting(
+ 'Comfy.NodeSearchBoxImpl',
+ 'v1 (legacy)'
+ )
+
// Setup workflow with a KSampler node
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nodeOps.waitForGraphNodes(0)
diff --git a/packages/design-system/src/css/style.css b/packages/design-system/src/css/style.css
index 88487f0a18..08ee3570c7 100644
--- a/packages/design-system/src/css/style.css
+++ b/packages/design-system/src/css/style.css
@@ -12,6 +12,12 @@
icon-sets: from-folder(comfy, './packages/design-system/src/icons');
}
+/* Safelist dynamic comfy icons for node library folders */
+@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,extensions-blocks,file-output,gemini,grok,hitpaw,ideogram,image-ai-edit,kling,ltxv,luma,magnific,mask,meshy,minimax,moonvalley-marey,node,openai,pin,pixverse,play,recraft,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow}]");
+
+/* Safelist dynamic comfy icons for essential nodes (kebab-case of node names) */
+@source inline("icon-[comfy--{load-image,save-image,load-video,save-video,load-3-d,save-glb,image-batch,image-crop,image-scale,image-rotate,image-blur,image-invert,canny,recraft-remove-background-node,kling-lip-sync-audio-to-video-node,load-audio,save-audio,stability-text-to-audio,lora-loader,clip-text-encode,get-video-components,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,subgraph-blueprint-canny-to-video-ltx-2-0,subgraph-blueprint-pose-to-video-ltx-2-0}]");
+
@custom-variant touch (@media (hover: none));
@theme {
diff --git a/packages/design-system/src/icons/bfl.svg b/packages/design-system/src/icons/bfl.svg
new file mode 100644
index 0000000000..74ce48cc94
--- /dev/null
+++ b/packages/design-system/src/icons/bfl.svg
@@ -0,0 +1,10 @@
+
diff --git a/packages/design-system/src/icons/bria.svg b/packages/design-system/src/icons/bria.svg
new file mode 100644
index 0000000000..20d6e66aa8
--- /dev/null
+++ b/packages/design-system/src/icons/bria.svg
@@ -0,0 +1,18 @@
+
diff --git a/packages/design-system/src/icons/bytedance.svg b/packages/design-system/src/icons/bytedance.svg
new file mode 100644
index 0000000000..54309f7ccd
--- /dev/null
+++ b/packages/design-system/src/icons/bytedance.svg
@@ -0,0 +1,13 @@
+
diff --git a/packages/design-system/src/icons/canny.svg b/packages/design-system/src/icons/canny.svg
new file mode 100644
index 0000000000..6bae945a26
--- /dev/null
+++ b/packages/design-system/src/icons/canny.svg
@@ -0,0 +1,10 @@
+
diff --git a/packages/design-system/src/icons/canny_to_image.svg b/packages/design-system/src/icons/canny_to_image.svg
new file mode 100644
index 0000000000..aaeb32d051
--- /dev/null
+++ b/packages/design-system/src/icons/canny_to_image.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/design-system/src/icons/clip-text-encode.svg b/packages/design-system/src/icons/clip-text-encode.svg
new file mode 100644
index 0000000000..ea6fb966b2
--- /dev/null
+++ b/packages/design-system/src/icons/clip-text-encode.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/compare_images.svg b/packages/design-system/src/icons/compare_images.svg
new file mode 100644
index 0000000000..a1d8ad0e5a
--- /dev/null
+++ b/packages/design-system/src/icons/compare_images.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/design-system/src/icons/compare_videos.svg b/packages/design-system/src/icons/compare_videos.svg
new file mode 100644
index 0000000000..75ca914356
--- /dev/null
+++ b/packages/design-system/src/icons/compare_videos.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/design-system/src/icons/credits.svg b/packages/design-system/src/icons/credits.svg
new file mode 100644
index 0000000000..f806cddeed
--- /dev/null
+++ b/packages/design-system/src/icons/credits.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/crop_video.svg b/packages/design-system/src/icons/crop_video.svg
new file mode 100644
index 0000000000..d2d5508a01
--- /dev/null
+++ b/packages/design-system/src/icons/crop_video.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/design-system/src/icons/depth_to_image.svg b/packages/design-system/src/icons/depth_to_image.svg
new file mode 100644
index 0000000000..d46ea269db
--- /dev/null
+++ b/packages/design-system/src/icons/depth_to_image.svg
@@ -0,0 +1,12 @@
+
diff --git a/packages/design-system/src/icons/depth_to_video.svg b/packages/design-system/src/icons/depth_to_video.svg
new file mode 100644
index 0000000000..9edb63ecc2
--- /dev/null
+++ b/packages/design-system/src/icons/depth_to_video.svg
@@ -0,0 +1,12 @@
+
diff --git a/packages/design-system/src/icons/edit_image.svg b/packages/design-system/src/icons/edit_image.svg
new file mode 100644
index 0000000000..6ccc96753e
--- /dev/null
+++ b/packages/design-system/src/icons/edit_image.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/edit_video.svg b/packages/design-system/src/icons/edit_video.svg
new file mode 100644
index 0000000000..ecb0acb01e
--- /dev/null
+++ b/packages/design-system/src/icons/edit_video.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/design-system/src/icons/enhance.svg b/packages/design-system/src/icons/enhance.svg
new file mode 100644
index 0000000000..3a3534c2bd
--- /dev/null
+++ b/packages/design-system/src/icons/enhance.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/enhance_3dmodel.svg b/packages/design-system/src/icons/enhance_3dmodel.svg
new file mode 100644
index 0000000000..e5d03491c3
--- /dev/null
+++ b/packages/design-system/src/icons/enhance_3dmodel.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/design-system/src/icons/enhance_video.svg b/packages/design-system/src/icons/enhance_video.svg
new file mode 100644
index 0000000000..93ccad2cfa
--- /dev/null
+++ b/packages/design-system/src/icons/enhance_video.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/design-system/src/icons/gemini.svg b/packages/design-system/src/icons/gemini.svg
new file mode 100644
index 0000000000..d2f0fc7928
--- /dev/null
+++ b/packages/design-system/src/icons/gemini.svg
@@ -0,0 +1,25 @@
+
diff --git a/packages/design-system/src/icons/get-video-components.svg b/packages/design-system/src/icons/get-video-components.svg
new file mode 100644
index 0000000000..7bca20f5cd
--- /dev/null
+++ b/packages/design-system/src/icons/get-video-components.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/grok.svg b/packages/design-system/src/icons/grok.svg
new file mode 100644
index 0000000000..ac0e596fb9
--- /dev/null
+++ b/packages/design-system/src/icons/grok.svg
@@ -0,0 +1,10 @@
+
diff --git a/packages/design-system/src/icons/hitpaw.svg b/packages/design-system/src/icons/hitpaw.svg
new file mode 100644
index 0000000000..7785527403
--- /dev/null
+++ b/packages/design-system/src/icons/hitpaw.svg
@@ -0,0 +1,10 @@
+
diff --git a/packages/design-system/src/icons/ideogram.svg b/packages/design-system/src/icons/ideogram.svg
new file mode 100644
index 0000000000..a4b77cb475
--- /dev/null
+++ b/packages/design-system/src/icons/ideogram.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/image-batch.svg b/packages/design-system/src/icons/image-batch.svg
new file mode 100644
index 0000000000..6c2c3913ba
--- /dev/null
+++ b/packages/design-system/src/icons/image-batch.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/image-blur.svg b/packages/design-system/src/icons/image-blur.svg
new file mode 100644
index 0000000000..a59f889931
--- /dev/null
+++ b/packages/design-system/src/icons/image-blur.svg
@@ -0,0 +1,11 @@
+
diff --git a/packages/design-system/src/icons/image-crop.svg b/packages/design-system/src/icons/image-crop.svg
new file mode 100644
index 0000000000..d0cddd2399
--- /dev/null
+++ b/packages/design-system/src/icons/image-crop.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/image-invert.svg b/packages/design-system/src/icons/image-invert.svg
new file mode 100644
index 0000000000..98087cac3d
--- /dev/null
+++ b/packages/design-system/src/icons/image-invert.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/design-system/src/icons/image-rotate.svg b/packages/design-system/src/icons/image-rotate.svg
new file mode 100644
index 0000000000..d565dc6403
--- /dev/null
+++ b/packages/design-system/src/icons/image-rotate.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/design-system/src/icons/image-scale.svg b/packages/design-system/src/icons/image-scale.svg
new file mode 100644
index 0000000000..09160d715f
--- /dev/null
+++ b/packages/design-system/src/icons/image-scale.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/image_captioning.svg b/packages/design-system/src/icons/image_captioning.svg
new file mode 100644
index 0000000000..38892dac13
--- /dev/null
+++ b/packages/design-system/src/icons/image_captioning.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/image_color_adjust.svg b/packages/design-system/src/icons/image_color_adjust.svg
new file mode 100644
index 0000000000..4ba7fa877e
--- /dev/null
+++ b/packages/design-system/src/icons/image_color_adjust.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/image_compositor.svg b/packages/design-system/src/icons/image_compositor.svg
new file mode 100644
index 0000000000..8d06c0427b
--- /dev/null
+++ b/packages/design-system/src/icons/image_compositor.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/image_depth.svg b/packages/design-system/src/icons/image_depth.svg
new file mode 100644
index 0000000000..076915dd87
--- /dev/null
+++ b/packages/design-system/src/icons/image_depth.svg
@@ -0,0 +1,15 @@
+
diff --git a/packages/design-system/src/icons/image_iterator.svg b/packages/design-system/src/icons/image_iterator.svg
new file mode 100644
index 0000000000..6b53161d64
--- /dev/null
+++ b/packages/design-system/src/icons/image_iterator.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/image_normalmap.svg b/packages/design-system/src/icons/image_normalmap.svg
new file mode 100644
index 0000000000..177107bdc2
--- /dev/null
+++ b/packages/design-system/src/icons/image_normalmap.svg
@@ -0,0 +1,21 @@
+
diff --git a/packages/design-system/src/icons/image_pose.svg b/packages/design-system/src/icons/image_pose.svg
new file mode 100644
index 0000000000..bdf23df3b0
--- /dev/null
+++ b/packages/design-system/src/icons/image_pose.svg
@@ -0,0 +1,10 @@
+
diff --git a/packages/design-system/src/icons/image_shader.svg b/packages/design-system/src/icons/image_shader.svg
new file mode 100644
index 0000000000..10d623411c
--- /dev/null
+++ b/packages/design-system/src/icons/image_shader.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/design-system/src/icons/image_to_image.svg b/packages/design-system/src/icons/image_to_image.svg
new file mode 100644
index 0000000000..6491f5362b
--- /dev/null
+++ b/packages/design-system/src/icons/image_to_image.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/design-system/src/icons/image_to_layers.svg b/packages/design-system/src/icons/image_to_layers.svg
new file mode 100644
index 0000000000..43d9d2ac02
--- /dev/null
+++ b/packages/design-system/src/icons/image_to_layers.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/design-system/src/icons/image_to_video.svg b/packages/design-system/src/icons/image_to_video.svg
new file mode 100644
index 0000000000..0052f1d308
--- /dev/null
+++ b/packages/design-system/src/icons/image_to_video.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/design-system/src/icons/inpaint_image.svg b/packages/design-system/src/icons/inpaint_image.svg
new file mode 100644
index 0000000000..b3959fa2b3
--- /dev/null
+++ b/packages/design-system/src/icons/inpaint_image.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/design-system/src/icons/inpaint_video.svg b/packages/design-system/src/icons/inpaint_video.svg
new file mode 100644
index 0000000000..965bb3fcbd
--- /dev/null
+++ b/packages/design-system/src/icons/inpaint_video.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/design-system/src/icons/kling-lip-sync-audio-to-video-node.svg b/packages/design-system/src/icons/kling-lip-sync-audio-to-video-node.svg
new file mode 100644
index 0000000000..4a74ae4845
--- /dev/null
+++ b/packages/design-system/src/icons/kling-lip-sync-audio-to-video-node.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/kling.svg b/packages/design-system/src/icons/kling.svg
new file mode 100644
index 0000000000..32dc0f806e
--- /dev/null
+++ b/packages/design-system/src/icons/kling.svg
@@ -0,0 +1,38 @@
+
diff --git a/packages/design-system/src/icons/layers_to_image.svg b/packages/design-system/src/icons/layers_to_image.svg
new file mode 100644
index 0000000000..3e9dfb3a4b
--- /dev/null
+++ b/packages/design-system/src/icons/layers_to_image.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/design-system/src/icons/load-3-d.svg b/packages/design-system/src/icons/load-3-d.svg
new file mode 100644
index 0000000000..2c6ea830e0
--- /dev/null
+++ b/packages/design-system/src/icons/load-3-d.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/load-audio.svg b/packages/design-system/src/icons/load-audio.svg
new file mode 100644
index 0000000000..8276d1a80f
--- /dev/null
+++ b/packages/design-system/src/icons/load-audio.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/design-system/src/icons/load-image.svg b/packages/design-system/src/icons/load-image.svg
new file mode 100644
index 0000000000..8da07a860d
--- /dev/null
+++ b/packages/design-system/src/icons/load-image.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/load-video.svg b/packages/design-system/src/icons/load-video.svg
new file mode 100644
index 0000000000..17cfbc6c13
--- /dev/null
+++ b/packages/design-system/src/icons/load-video.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/lora-loader.svg b/packages/design-system/src/icons/lora-loader.svg
new file mode 100644
index 0000000000..d96f7d7a6e
--- /dev/null
+++ b/packages/design-system/src/icons/lora-loader.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/design-system/src/icons/ltxv.svg b/packages/design-system/src/icons/ltxv.svg
new file mode 100644
index 0000000000..0f2db5f5e1
--- /dev/null
+++ b/packages/design-system/src/icons/ltxv.svg
@@ -0,0 +1,10 @@
+
diff --git a/packages/design-system/src/icons/luma.svg b/packages/design-system/src/icons/luma.svg
new file mode 100644
index 0000000000..a124becca8
--- /dev/null
+++ b/packages/design-system/src/icons/luma.svg
@@ -0,0 +1,35 @@
+
diff --git a/packages/design-system/src/icons/magnific.svg b/packages/design-system/src/icons/magnific.svg
new file mode 100644
index 0000000000..67d06535d6
--- /dev/null
+++ b/packages/design-system/src/icons/magnific.svg
@@ -0,0 +1,14 @@
+
diff --git a/packages/design-system/src/icons/meshy.svg b/packages/design-system/src/icons/meshy.svg
new file mode 100644
index 0000000000..e0bdc68ff1
--- /dev/null
+++ b/packages/design-system/src/icons/meshy.svg
@@ -0,0 +1,33 @@
+
diff --git a/packages/design-system/src/icons/minimax.svg b/packages/design-system/src/icons/minimax.svg
new file mode 100644
index 0000000000..a72b1c52a6
--- /dev/null
+++ b/packages/design-system/src/icons/minimax.svg
@@ -0,0 +1,14 @@
+
diff --git a/packages/design-system/src/icons/moonvalley-marey.svg b/packages/design-system/src/icons/moonvalley-marey.svg
new file mode 100644
index 0000000000..f8201735f8
--- /dev/null
+++ b/packages/design-system/src/icons/moonvalley-marey.svg
@@ -0,0 +1,18 @@
+
diff --git a/packages/design-system/src/icons/music_generation.svg b/packages/design-system/src/icons/music_generation.svg
new file mode 100644
index 0000000000..9df20c85f8
--- /dev/null
+++ b/packages/design-system/src/icons/music_generation.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/design-system/src/icons/open-ai-chat-node.svg b/packages/design-system/src/icons/open-ai-chat-node.svg
new file mode 100644
index 0000000000..48b44236ab
--- /dev/null
+++ b/packages/design-system/src/icons/open-ai-chat-node.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/openai.svg b/packages/design-system/src/icons/openai.svg
new file mode 100644
index 0000000000..ec77e07300
--- /dev/null
+++ b/packages/design-system/src/icons/openai.svg
@@ -0,0 +1,10 @@
+
diff --git a/packages/design-system/src/icons/outpaint_image.svg b/packages/design-system/src/icons/outpaint_image.svg
new file mode 100644
index 0000000000..03e603f506
--- /dev/null
+++ b/packages/design-system/src/icons/outpaint_image.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/design-system/src/icons/painter.svg b/packages/design-system/src/icons/painter.svg
new file mode 100644
index 0000000000..2557a7d85e
--- /dev/null
+++ b/packages/design-system/src/icons/painter.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/pixverse.svg b/packages/design-system/src/icons/pixverse.svg
new file mode 100644
index 0000000000..5fcad5cd88
--- /dev/null
+++ b/packages/design-system/src/icons/pixverse.svg
@@ -0,0 +1,15 @@
+
diff --git a/packages/design-system/src/icons/pose_to_image.svg b/packages/design-system/src/icons/pose_to_image.svg
new file mode 100644
index 0000000000..05dc681f7d
--- /dev/null
+++ b/packages/design-system/src/icons/pose_to_image.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/design-system/src/icons/prompt_enhance.svg b/packages/design-system/src/icons/prompt_enhance.svg
new file mode 100644
index 0000000000..e1265b9347
--- /dev/null
+++ b/packages/design-system/src/icons/prompt_enhance.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/recraft-remove-background-node.svg b/packages/design-system/src/icons/recraft-remove-background-node.svg
new file mode 100644
index 0000000000..51497b57c9
--- /dev/null
+++ b/packages/design-system/src/icons/recraft-remove-background-node.svg
@@ -0,0 +1,10 @@
+
diff --git a/packages/design-system/src/icons/recraft.svg b/packages/design-system/src/icons/recraft.svg
new file mode 100644
index 0000000000..ff93cb8403
--- /dev/null
+++ b/packages/design-system/src/icons/recraft.svg
@@ -0,0 +1,11 @@
+
diff --git a/packages/design-system/src/icons/resize_video.svg b/packages/design-system/src/icons/resize_video.svg
new file mode 100644
index 0000000000..fe2b45be98
--- /dev/null
+++ b/packages/design-system/src/icons/resize_video.svg
@@ -0,0 +1,11 @@
+
diff --git a/packages/design-system/src/icons/rodin.svg b/packages/design-system/src/icons/rodin.svg
new file mode 100644
index 0000000000..f628f41bfe
--- /dev/null
+++ b/packages/design-system/src/icons/rodin.svg
@@ -0,0 +1,18 @@
+
diff --git a/packages/design-system/src/icons/rotate_video.svg b/packages/design-system/src/icons/rotate_video.svg
new file mode 100644
index 0000000000..8c257ac9e2
--- /dev/null
+++ b/packages/design-system/src/icons/rotate_video.svg
@@ -0,0 +1,17 @@
+
diff --git a/packages/design-system/src/icons/runway.svg b/packages/design-system/src/icons/runway.svg
new file mode 100644
index 0000000000..6cc20d7498
--- /dev/null
+++ b/packages/design-system/src/icons/runway.svg
@@ -0,0 +1,10 @@
+
diff --git a/packages/design-system/src/icons/save-audio.svg b/packages/design-system/src/icons/save-audio.svg
new file mode 100644
index 0000000000..07360f8ab5
--- /dev/null
+++ b/packages/design-system/src/icons/save-audio.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/design-system/src/icons/save-glb.svg b/packages/design-system/src/icons/save-glb.svg
new file mode 100644
index 0000000000..aa25867173
--- /dev/null
+++ b/packages/design-system/src/icons/save-glb.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/save-image.svg b/packages/design-system/src/icons/save-image.svg
new file mode 100644
index 0000000000..f137b72425
--- /dev/null
+++ b/packages/design-system/src/icons/save-image.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/save-video.svg b/packages/design-system/src/icons/save-video.svg
new file mode 100644
index 0000000000..edf324add6
--- /dev/null
+++ b/packages/design-system/src/icons/save-video.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/select_object.svg b/packages/design-system/src/icons/select_object.svg
new file mode 100644
index 0000000000..6f9532bcec
--- /dev/null
+++ b/packages/design-system/src/icons/select_object.svg
@@ -0,0 +1,10 @@
+
diff --git a/packages/design-system/src/icons/sora.svg b/packages/design-system/src/icons/sora.svg
new file mode 100644
index 0000000000..5d2c8b4967
--- /dev/null
+++ b/packages/design-system/src/icons/sora.svg
@@ -0,0 +1,9 @@
+
diff --git a/packages/design-system/src/icons/stability-ai.svg b/packages/design-system/src/icons/stability-ai.svg
new file mode 100644
index 0000000000..c84cae68ed
--- /dev/null
+++ b/packages/design-system/src/icons/stability-ai.svg
@@ -0,0 +1,15 @@
+
diff --git a/packages/design-system/src/icons/stability-text-to-audio.svg b/packages/design-system/src/icons/stability-text-to-audio.svg
new file mode 100644
index 0000000000..d642ece211
--- /dev/null
+++ b/packages/design-system/src/icons/stability-text-to-audio.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/design-system/src/icons/stitch_videos.svg b/packages/design-system/src/icons/stitch_videos.svg
new file mode 100644
index 0000000000..0396bb1f6c
--- /dev/null
+++ b/packages/design-system/src/icons/stitch_videos.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/subgraph-blueprint-canny-to-video-ltx-2-0.svg b/packages/design-system/src/icons/subgraph-blueprint-canny-to-video-ltx-2-0.svg
new file mode 100644
index 0000000000..f3f654b75b
--- /dev/null
+++ b/packages/design-system/src/icons/subgraph-blueprint-canny-to-video-ltx-2-0.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/design-system/src/icons/subgraph-blueprint-pose-to-video-ltx-2-0.svg b/packages/design-system/src/icons/subgraph-blueprint-pose-to-video-ltx-2-0.svg
new file mode 100644
index 0000000000..cfdaa8630e
--- /dev/null
+++ b/packages/design-system/src/icons/subgraph-blueprint-pose-to-video-ltx-2-0.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/design-system/src/icons/tencent-image-to-model-node.svg b/packages/design-system/src/icons/tencent-image-to-model-node.svg
new file mode 100644
index 0000000000..d723246bf7
--- /dev/null
+++ b/packages/design-system/src/icons/tencent-image-to-model-node.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/design-system/src/icons/tencent-text-to-model-node.svg b/packages/design-system/src/icons/tencent-text-to-model-node.svg
new file mode 100644
index 0000000000..43fa9f0b25
--- /dev/null
+++ b/packages/design-system/src/icons/tencent-text-to-model-node.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/design-system/src/icons/tencent.svg b/packages/design-system/src/icons/tencent.svg
new file mode 100644
index 0000000000..7eaa231218
--- /dev/null
+++ b/packages/design-system/src/icons/tencent.svg
@@ -0,0 +1,23 @@
+
diff --git a/packages/design-system/src/icons/text_iterator.svg b/packages/design-system/src/icons/text_iterator.svg
new file mode 100644
index 0000000000..4b835d17cd
--- /dev/null
+++ b/packages/design-system/src/icons/text_iterator.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/text_to_image.svg b/packages/design-system/src/icons/text_to_image.svg
new file mode 100644
index 0000000000..d4c61c765f
--- /dev/null
+++ b/packages/design-system/src/icons/text_to_image.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/design-system/src/icons/text_to_video.svg b/packages/design-system/src/icons/text_to_video.svg
new file mode 100644
index 0000000000..88ccdb9d18
--- /dev/null
+++ b/packages/design-system/src/icons/text_to_video.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/design-system/src/icons/topaz.svg b/packages/design-system/src/icons/topaz.svg
new file mode 100644
index 0000000000..f83e03ebcd
--- /dev/null
+++ b/packages/design-system/src/icons/topaz.svg
@@ -0,0 +1,10 @@
+
diff --git a/packages/design-system/src/icons/trim_video.svg b/packages/design-system/src/icons/trim_video.svg
new file mode 100644
index 0000000000..b8d842251e
--- /dev/null
+++ b/packages/design-system/src/icons/trim_video.svg
@@ -0,0 +1,5 @@
+
diff --git a/packages/design-system/src/icons/tripo.svg b/packages/design-system/src/icons/tripo.svg
new file mode 100644
index 0000000000..8de6a38c4f
--- /dev/null
+++ b/packages/design-system/src/icons/tripo.svg
@@ -0,0 +1,16 @@
+
diff --git a/packages/design-system/src/icons/vectorize.svg b/packages/design-system/src/icons/vectorize.svg
new file mode 100644
index 0000000000..33fe18a847
--- /dev/null
+++ b/packages/design-system/src/icons/vectorize.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/veo.svg b/packages/design-system/src/icons/veo.svg
new file mode 100644
index 0000000000..c92e4c632b
--- /dev/null
+++ b/packages/design-system/src/icons/veo.svg
@@ -0,0 +1,13 @@
+
diff --git a/packages/design-system/src/icons/video_canny.svg b/packages/design-system/src/icons/video_canny.svg
new file mode 100644
index 0000000000..4ecc9cc066
--- /dev/null
+++ b/packages/design-system/src/icons/video_canny.svg
@@ -0,0 +1,11 @@
+
diff --git a/packages/design-system/src/icons/video_captioning.svg b/packages/design-system/src/icons/video_captioning.svg
new file mode 100644
index 0000000000..ce63fe0774
--- /dev/null
+++ b/packages/design-system/src/icons/video_captioning.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/video_compositor.svg b/packages/design-system/src/icons/video_compositor.svg
new file mode 100644
index 0000000000..7b2bc2659b
--- /dev/null
+++ b/packages/design-system/src/icons/video_compositor.svg
@@ -0,0 +1,4 @@
+
diff --git a/packages/design-system/src/icons/video_depth.svg b/packages/design-system/src/icons/video_depth.svg
new file mode 100644
index 0000000000..c169d21654
--- /dev/null
+++ b/packages/design-system/src/icons/video_depth.svg
@@ -0,0 +1,15 @@
+
diff --git a/packages/design-system/src/icons/video_normal_map.svg b/packages/design-system/src/icons/video_normal_map.svg
new file mode 100644
index 0000000000..5fc4283699
--- /dev/null
+++ b/packages/design-system/src/icons/video_normal_map.svg
@@ -0,0 +1,23 @@
+
diff --git a/packages/design-system/src/icons/video_shaders.svg b/packages/design-system/src/icons/video_shaders.svg
new file mode 100644
index 0000000000..8d8d7bc1de
--- /dev/null
+++ b/packages/design-system/src/icons/video_shaders.svg
@@ -0,0 +1,6 @@
+
diff --git a/packages/design-system/src/icons/vidu.svg b/packages/design-system/src/icons/vidu.svg
new file mode 100644
index 0000000000..3000b25bf7
--- /dev/null
+++ b/packages/design-system/src/icons/vidu.svg
@@ -0,0 +1,24 @@
+
diff --git a/packages/design-system/src/icons/voice_clone.svg b/packages/design-system/src/icons/voice_clone.svg
new file mode 100644
index 0000000000..83b8062884
--- /dev/null
+++ b/packages/design-system/src/icons/voice_clone.svg
@@ -0,0 +1,3 @@
+
diff --git a/packages/design-system/src/icons/wan.svg b/packages/design-system/src/icons/wan.svg
new file mode 100644
index 0000000000..ed9b8c6d2a
--- /dev/null
+++ b/packages/design-system/src/icons/wan.svg
@@ -0,0 +1,13 @@
+
diff --git a/packages/design-system/src/icons/wavespeed.svg b/packages/design-system/src/icons/wavespeed.svg
new file mode 100644
index 0000000000..d04d8365b5
--- /dev/null
+++ b/packages/design-system/src/icons/wavespeed.svg
@@ -0,0 +1,12 @@
+
diff --git a/packages/shared-frontend-utils/package.json b/packages/shared-frontend-utils/package.json
index 0a9d947abb..aa18c7a940 100644
--- a/packages/shared-frontend-utils/package.json
+++ b/packages/shared-frontend-utils/package.json
@@ -13,7 +13,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "axios": "catalog:"
+ "axios": "catalog:",
+ "dompurify": "catalog:"
},
"devDependencies": {
"typescript": "catalog:"
diff --git a/packages/shared-frontend-utils/src/formatUtil.test.ts b/packages/shared-frontend-utils/src/formatUtil.test.ts
index 75d8b8db23..9b793fe910 100644
--- a/packages/shared-frontend-utils/src/formatUtil.test.ts
+++ b/packages/shared-frontend-utils/src/formatUtil.test.ts
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
-import { getMediaTypeFromFilename, truncateFilename } from './formatUtil'
+import {
+ getMediaTypeFromFilename,
+ highlightQuery,
+ truncateFilename
+} from './formatUtil'
describe('formatUtil', () => {
describe('truncateFilename', () => {
@@ -142,4 +146,42 @@ describe('formatUtil', () => {
})
})
})
+
+ describe('highlightQuery', () => {
+ it('should return text unchanged when query is empty', () => {
+ expect(highlightQuery('Hello World', '')).toBe('Hello World')
+ })
+
+ it('should wrap matching text in highlight span', () => {
+ const result = highlightQuery('Hello World', 'World')
+ expect(result).toBe('Hello World')
+ })
+
+ it('should be case-insensitive', () => {
+ const result = highlightQuery('Hello World', 'hello')
+ expect(result).toBe('Hello World')
+ })
+
+ it('should sanitize text by default', () => {
+ const result = highlightQuery('', 'alert')
+ expect(result).not.toContain('
diff --git a/src/components/common/SearchBoxV2.test.ts b/src/components/common/SearchBoxV2.test.ts
new file mode 100644
index 0000000000..dfb2feaaf9
--- /dev/null
+++ b/src/components/common/SearchBoxV2.test.ts
@@ -0,0 +1,90 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import { createI18n } from 'vue-i18n'
+
+import SearchBoxV2 from './SearchBoxV2.vue'
+
+vi.mock('@vueuse/core', () => ({
+ watchDebounced: vi.fn(() => vi.fn())
+}))
+
+describe('SearchBoxV2', () => {
+ const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {
+ g: {
+ clear: 'Clear',
+ searchPlaceholder: 'Search...'
+ }
+ }
+ }
+ })
+
+ function mountComponent(props = {}) {
+ return mount(SearchBoxV2, {
+ global: {
+ plugins: [i18n],
+ stubs: {
+ ComboboxRoot: {
+ template: '
'
+ },
+ ComboboxAnchor: {
+ template: '
'
+ },
+ ComboboxInput: {
+ template:
+ '',
+ props: ['placeholder', 'modelValue', 'autoFocus']
+ }
+ }
+ },
+ props: {
+ modelValue: '',
+ ...props
+ }
+ })
+ }
+
+ it('uses i18n placeholder when no placeholder prop provided', () => {
+ const wrapper = mountComponent()
+ const input = wrapper.find('input')
+ expect(input.attributes('placeholder')).toBe('Search...')
+ })
+
+ it('uses custom placeholder when provided', () => {
+ const wrapper = mountComponent({
+ placeholder: 'Custom placeholder'
+ })
+ const input = wrapper.find('input')
+ expect(input.attributes('placeholder')).toBe('Custom placeholder')
+ })
+
+ it('shows search icon when search term is empty', () => {
+ const wrapper = mountComponent({ modelValue: '' })
+ expect(wrapper.find('i.icon-\\[lucide--search\\]').exists()).toBe(true)
+ })
+
+ it('shows clear button when search term is not empty', () => {
+ const wrapper = mountComponent({ modelValue: 'test' })
+ expect(wrapper.find('button').exists()).toBe(true)
+ })
+
+ it('clears search term when clear button is clicked', async () => {
+ const wrapper = mountComponent({ modelValue: 'test' })
+ const clearButton = wrapper.find('button')
+ await clearButton.trigger('click')
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([''])
+ })
+
+ it('applies large size classes when size is lg', () => {
+ const wrapper = mountComponent({ size: 'lg' })
+ expect(wrapper.html()).toContain('size-5')
+ })
+
+ it('applies medium size classes when size is md', () => {
+ const wrapper = mountComponent({ size: 'md' })
+ expect(wrapper.html()).toContain('size-4')
+ })
+})
diff --git a/src/components/common/SearchBoxV2.vue b/src/components/common/SearchBoxV2.vue
new file mode 100644
index 0000000000..82998501d3
--- /dev/null
+++ b/src/components/common/SearchBoxV2.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/common/TextTicker.test.ts b/src/components/common/TextTicker.test.ts
new file mode 100644
index 0000000000..0d94b84b40
--- /dev/null
+++ b/src/components/common/TextTicker.test.ts
@@ -0,0 +1,122 @@
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import TextTicker from './TextTicker.vue'
+
+function mockScrollWidth(el: HTMLElement, scrollWidth: number) {
+ Object.defineProperty(el, 'scrollWidth', {
+ value: scrollWidth,
+ configurable: true
+ })
+}
+
+describe(TextTicker, () => {
+ let rafCallbacks: ((time: number) => void)[]
+ let wrapper: ReturnType
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ rafCallbacks = []
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
+ rafCallbacks.push(cb)
+ return rafCallbacks.length
+ })
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ wrapper?.unmount()
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ })
+
+ it('renders slot content', () => {
+ wrapper = mount(TextTicker, {
+ slots: { default: 'Hello World' }
+ })
+ expect(wrapper.text()).toBe('Hello World')
+ })
+
+ it('scrolls on hover after delay', async () => {
+ wrapper = mount(TextTicker, {
+ slots: { default: 'Very long text that overflows' },
+ props: { speed: 100 }
+ })
+
+ const el = wrapper.element as HTMLElement
+ mockScrollWidth(el, 300)
+
+ await nextTick()
+ await wrapper.trigger('mouseenter')
+ await nextTick()
+
+ expect(rafCallbacks.length).toBe(0)
+
+ vi.advanceTimersByTime(350)
+ await nextTick()
+ expect(rafCallbacks.length).toBeGreaterThan(0)
+
+ rafCallbacks[0](performance.now() + 500)
+ expect(el.scrollLeft).toBeGreaterThan(0)
+ })
+
+ it('cancels delayed scroll on mouse leave before delay elapses', async () => {
+ wrapper = mount(TextTicker, {
+ slots: { default: 'Very long text that overflows' },
+ props: { speed: 100 }
+ })
+
+ mockScrollWidth(wrapper.element as HTMLElement, 300)
+
+ await nextTick()
+ await wrapper.trigger('mouseenter')
+ await nextTick()
+
+ vi.advanceTimersByTime(200)
+ await wrapper.trigger('mouseleave')
+ await nextTick()
+
+ vi.advanceTimersByTime(350)
+ await nextTick()
+ expect(rafCallbacks.length).toBe(0)
+ })
+
+ it('resets scroll position on mouse leave', async () => {
+ wrapper = mount(TextTicker, {
+ slots: { default: 'Very long text that overflows' },
+ props: { speed: 100 }
+ })
+
+ const el = wrapper.element as HTMLElement
+ mockScrollWidth(el, 300)
+
+ await nextTick()
+ await wrapper.trigger('mouseenter')
+ await nextTick()
+ vi.advanceTimersByTime(350)
+ await nextTick()
+
+ rafCallbacks[0](performance.now() + 500)
+ expect(el.scrollLeft).toBeGreaterThan(0)
+
+ await wrapper.trigger('mouseleave')
+ await nextTick()
+
+ expect(el.scrollLeft).toBe(0)
+ })
+
+ it('does not scroll when content fits', async () => {
+ wrapper = mount(TextTicker, {
+ slots: { default: 'Short' }
+ })
+
+ await nextTick()
+ await wrapper.trigger('mouseenter')
+ await nextTick()
+ vi.advanceTimersByTime(350)
+ await nextTick()
+
+ expect(rafCallbacks.length).toBe(0)
+ })
+})
diff --git a/src/components/common/TextTicker.vue b/src/components/common/TextTicker.vue
new file mode 100644
index 0000000000..057a910667
--- /dev/null
+++ b/src/components/common/TextTicker.vue
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
diff --git a/src/components/common/TreeExplorerV2.vue b/src/components/common/TreeExplorerV2.vue
new file mode 100644
index 0000000000..6350f02b57
--- /dev/null
+++ b/src/components/common/TreeExplorerV2.vue
@@ -0,0 +1,113 @@
+
+
+
+
+ , e: MouseEvent) =>
+ emit('nodeClick', node, e)
+ "
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('sideToolbar.nodeLibraryTab.sections.favorites') }}
+
+
+
+
+
+
+
diff --git a/src/components/common/TreeExplorerV2Node.test.ts b/src/components/common/TreeExplorerV2Node.test.ts
new file mode 100644
index 0000000000..3ef9c7bd33
--- /dev/null
+++ b/src/components/common/TreeExplorerV2Node.test.ts
@@ -0,0 +1,321 @@
+import { mount } from '@vue/test-utils'
+import type { FlattenedItem } from 'reka-ui'
+import { ref } from 'vue'
+import { describe, expect, it, vi } from 'vitest'
+
+import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
+import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
+import { InjectKeyContextMenuNode } from '@/types/treeExplorerTypes'
+
+import TreeExplorerV2Node from './TreeExplorerV2Node.vue'
+
+vi.mock('@/platform/settings/settingStore', () => ({
+ useSettingStore: () => ({
+ get: vi.fn().mockReturnValue('left')
+ })
+}))
+
+vi.mock('@/components/node/NodePreviewCard.vue', () => ({
+ default: { template: '' }
+}))
+
+const mockStartDrag = vi.fn()
+const mockHandleNativeDrop = vi.fn()
+
+vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
+ useNodeDragToCanvas: () => ({
+ startDrag: mockStartDrag,
+ handleNativeDrop: mockHandleNativeDrop
+ })
+}))
+
+describe('TreeExplorerV2Node', () => {
+ function createMockItem(
+ type: 'node' | 'folder',
+ overrides: Record = {}
+ ): FlattenedItem> {
+ const value = {
+ key: 'test-key',
+ label: 'Test Label',
+ type,
+ icon: 'pi pi-folder',
+ totalLeaves: 5,
+ ...overrides
+ } as RenderedTreeExplorerNode
+ return {
+ _id: 'test-id',
+ index: 0,
+ value,
+ level: 1,
+ hasChildren: type === 'folder',
+ bind: { value, level: 1 }
+ }
+ }
+
+ function createTreeItemStub() {
+ const handleToggle = vi.fn()
+ const handleSelect = vi.fn()
+ return {
+ handleToggle,
+ handleSelect,
+ stub: {
+ template: `
`,
+ setup() {
+ return { handleToggle, handleSelect }
+ }
+ }
+ }
+ }
+
+ function mountComponent(
+ props: Record = {},
+ options: {
+ provide?: Record
+ treeItemStub?: ReturnType
+ } = {}
+ ) {
+ const treeItemStub = options.treeItemStub ?? createTreeItemStub()
+ return {
+ wrapper: mount(TreeExplorerV2Node, {
+ global: {
+ stubs: {
+ TreeItem: treeItemStub.stub,
+ ContextMenuTrigger: {
+ name: 'ContextMenuTrigger',
+ template: '
'
+ },
+ Teleport: { template: '' }
+ },
+ provide: {
+ ...options.provide
+ }
+ },
+ props: {
+ item: createMockItem('node'),
+ ...props
+ }
+ }),
+ treeItemStub
+ }
+ }
+
+ describe('handleClick', () => {
+ it('emits nodeClick event when clicked', async () => {
+ const { wrapper } = mountComponent({
+ item: createMockItem('node')
+ })
+
+ const nodeDiv = wrapper.find('div.group\\/tree-node')
+ await nodeDiv.trigger('click')
+
+ expect(wrapper.emitted('nodeClick')).toBeTruthy()
+ expect(wrapper.emitted('nodeClick')?.[0]?.[0]).toMatchObject({
+ type: 'node',
+ label: 'Test Label'
+ })
+ })
+
+ it('calls handleToggle for folder items', async () => {
+ const treeItemStub = createTreeItemStub()
+ const { wrapper } = mountComponent(
+ { item: createMockItem('folder') },
+ { treeItemStub }
+ )
+
+ const folderDiv = wrapper.find('div.group\\/tree-node')
+ await folderDiv.trigger('click')
+
+ expect(wrapper.emitted('nodeClick')).toBeTruthy()
+ expect(treeItemStub.handleToggle).toHaveBeenCalled()
+ })
+
+ it('does not call handleToggle for node items', async () => {
+ const treeItemStub = createTreeItemStub()
+ const { wrapper } = mountComponent(
+ { item: createMockItem('node') },
+ { treeItemStub }
+ )
+
+ const nodeDiv = wrapper.find('div.group\\/tree-node')
+ await nodeDiv.trigger('click')
+
+ expect(wrapper.emitted('nodeClick')).toBeTruthy()
+ expect(treeItemStub.handleToggle).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('context menu', () => {
+ it('renders ContextMenuTrigger when showContextMenu is true for nodes', () => {
+ const { wrapper } = mountComponent({
+ item: createMockItem('node'),
+ showContextMenu: true
+ })
+
+ expect(
+ wrapper.find('[data-testid="context-menu-trigger"]').exists()
+ ).toBe(true)
+ })
+
+ it('does not render ContextMenuTrigger for folder items', () => {
+ const { wrapper } = mountComponent({
+ item: createMockItem('folder')
+ })
+
+ expect(
+ wrapper.find('[data-testid="context-menu-trigger"]').exists()
+ ).toBe(false)
+ })
+
+ it('sets contextMenuNode when contextmenu event is triggered', async () => {
+ const contextMenuNode = ref(null)
+ const nodeItem = createMockItem('node')
+
+ const { wrapper } = mountComponent(
+ {
+ item: nodeItem,
+ showContextMenu: true
+ },
+ {
+ provide: {
+ [InjectKeyContextMenuNode as symbol]: contextMenuNode
+ }
+ }
+ )
+
+ const nodeDiv = wrapper.find('div.group\\/tree-node')
+ await nodeDiv.trigger('contextmenu')
+
+ expect(contextMenuNode.value).toEqual(nodeItem.value)
+ })
+ })
+
+ describe('rendering', () => {
+ it('renders node icon for node type', () => {
+ const { wrapper } = mountComponent({
+ item: createMockItem('node')
+ })
+
+ expect(wrapper.find('i.icon-\\[comfy--node\\]').exists()).toBe(true)
+ })
+
+ it('renders folder icon for folder type', () => {
+ const { wrapper } = mountComponent({
+ item: createMockItem('folder', { icon: 'icon-[lucide--folder]' })
+ })
+
+ expect(wrapper.find('i.icon-\\[lucide--folder\\]').exists()).toBe(true)
+ })
+
+ it('renders label text', () => {
+ const { wrapper } = mountComponent({
+ item: createMockItem('node', { label: 'My Node' })
+ })
+
+ expect(wrapper.text()).toContain('My Node')
+ })
+
+ it('renders chevron for folder with children', () => {
+ const { wrapper } = mountComponent({
+ item: {
+ ...createMockItem('folder'),
+ hasChildren: true
+ }
+ })
+
+ expect(wrapper.find('i.icon-\\[lucide--chevron-down\\]').exists()).toBe(
+ true
+ )
+ })
+ })
+
+ describe('drag and drop', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('sets draggable attribute on node items', () => {
+ const { wrapper } = mountComponent({
+ item: createMockItem('node')
+ })
+
+ const nodeDiv = wrapper.find('div.group\\/tree-node')
+ expect(nodeDiv.attributes('draggable')).toBe('true')
+ })
+
+ it('does not set draggable on folder items', () => {
+ const { wrapper } = mountComponent({
+ item: createMockItem('folder')
+ })
+
+ const folderDiv = wrapper.find('div.group\\/tree-node')
+ expect(folderDiv.attributes('draggable')).toBeUndefined()
+ })
+
+ it('calls startDrag with native mode on dragstart', async () => {
+ const mockData = { name: 'TestNode' }
+ const { wrapper } = mountComponent({
+ item: createMockItem('node', { data: mockData })
+ })
+
+ const nodeDiv = wrapper.find('div.group\\/tree-node')
+ await nodeDiv.trigger('dragstart')
+
+ expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
+ })
+
+ it('does not call startDrag for folder items on dragstart', async () => {
+ const { wrapper } = mountComponent({
+ item: createMockItem('folder')
+ })
+
+ const folderDiv = wrapper.find('div.group\\/tree-node')
+ await folderDiv.trigger('dragstart')
+
+ expect(mockStartDrag).not.toHaveBeenCalled()
+ })
+
+ it('calls handleNativeDrop on dragend with drop coordinates', async () => {
+ const mockData = { name: 'TestNode' }
+ const { wrapper } = mountComponent({
+ item: createMockItem('node', { data: mockData })
+ })
+
+ const nodeDiv = wrapper.find('div.group\\/tree-node')
+
+ await nodeDiv.trigger('dragstart')
+
+ const dragEndEvent = new DragEvent('dragend', { bubbles: true })
+ Object.defineProperty(dragEndEvent, 'clientX', { value: 100 })
+ Object.defineProperty(dragEndEvent, 'clientY', { value: 200 })
+
+ await nodeDiv.element.dispatchEvent(dragEndEvent)
+ await wrapper.vm.$nextTick()
+
+ expect(mockHandleNativeDrop).toHaveBeenCalledWith(100, 200)
+ })
+
+ it('calls handleNativeDrop regardless of dropEffect', async () => {
+ const mockData = { name: 'TestNode' }
+ const { wrapper } = mountComponent({
+ item: createMockItem('node', { data: mockData })
+ })
+
+ const nodeDiv = wrapper.find('div.group\\/tree-node')
+
+ await nodeDiv.trigger('dragstart')
+ mockHandleNativeDrop.mockClear()
+
+ const dragEndEvent = new DragEvent('dragend', { bubbles: true })
+ Object.defineProperty(dragEndEvent, 'clientX', { value: 300 })
+ Object.defineProperty(dragEndEvent, 'clientY', { value: 400 })
+ Object.defineProperty(dragEndEvent, 'dataTransfer', {
+ value: { dropEffect: 'none' }
+ })
+
+ await nodeDiv.element.dispatchEvent(dragEndEvent)
+ await wrapper.vm.$nextTick()
+
+ expect(mockHandleNativeDrop).toHaveBeenCalledWith(300, 400)
+ })
+ })
+})
diff --git a/src/components/common/TreeExplorerV2Node.vue b/src/components/common/TreeExplorerV2Node.vue
new file mode 100644
index 0000000000..6291a4b2f6
--- /dev/null
+++ b/src/components/common/TreeExplorerV2Node.vue
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
+
+ {{ item.value.label }}
+
+
+
+
+
+
+
+
+
+
+ {{ item.value.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/node/NodePreviewCard.vue b/src/components/node/NodePreviewCard.vue
new file mode 100644
index 0000000000..437803ab24
--- /dev/null
+++ b/src/components/node/NodePreviewCard.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+ {{ nodeDef.display_name }}
+
+
+
+
+ {{ nodeDef.category.replaceAll('/', ' > ') }}
+
+
+
+
+
+
+
+
+
+
+ {{ nodeDef.description }}
+
+
+
+
+
+
+
+
+ {{ $t('nodeHelpPage.inputs') }}
+
+
+ {{ input.name }}
+ {{
+ input.type
+ }}
+
+
+
+
+
+
+ {{ $t('nodeHelpPage.outputs') }}
+
+
+ {{ output.name }}
+ {{
+ output.type
+ }}
+
+
+
+
+
+
+
diff --git a/src/components/node/NodePricingBadge.vue b/src/components/node/NodePricingBadge.vue
new file mode 100644
index 0000000000..74f63499d3
--- /dev/null
+++ b/src/components/node/NodePricingBadge.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
diff --git a/src/components/node/NodeProviderBadge.vue b/src/components/node/NodeProviderBadge.vue
new file mode 100644
index 0000000000..fa8fe19894
--- /dev/null
+++ b/src/components/node/NodeProviderBadge.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
diff --git a/src/components/searchbox/NodeSearchBox.vue b/src/components/searchbox/NodeSearchBox.vue
index fcf63b1eb0..747f829cc0 100644
--- a/src/components/searchbox/NodeSearchBox.vue
+++ b/src/components/searchbox/NodeSearchBox.vue
@@ -5,7 +5,7 @@
{
debouncedTrackSearch(query)
}
-const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
+const emit = defineEmits<{
+ addFilter: [filter: FuseFilterWithValue]
+ removeFilter: [filter: FuseFilterWithValue]
+ addNode: [nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent]
+}>()
// Track node selection and emit addNode event
-const onAddNode = (nodeDef: ComfyNodeDefImpl) => {
+function onAddNode(nodeDef: ComfyNodeDefImpl, event?: MouseEvent) {
telemetry?.trackNodeSearchResultSelected({
node_type: nodeDef.name,
last_query: currentQuery.value
})
- emit('addNode', nodeDef)
+ emit('addNode', nodeDef, event)
}
let inputElement: HTMLInputElement | null = null
diff --git a/src/components/searchbox/NodeSearchBoxPopover.vue b/src/components/searchbox/NodeSearchBoxPopover.vue
index d043d78364..20f0fa97f7 100644
--- a/src/components/searchbox/NodeSearchBoxPopover.vue
+++ b/src/components/searchbox/NodeSearchBoxPopover.vue
@@ -6,10 +6,13 @@
:dismissable-mask="dismissable"
:pt="{
root: {
- class: 'invisible-dialog-root',
- role: 'search'
+ class: useSearchBoxV2
+ ? 'w-4/5 min-w-[32rem] max-w-[56rem] border-0 bg-transparent mt-[10vh] max-md:w-[95%] max-md:min-w-0 overflow-visible'
+ : 'invisible-dialog-root'
+ },
+ mask: {
+ class: useSearchBoxV2 ? 'items-start' : 'node-search-box-dialog-mask'
},
- mask: { class: 'node-search-box-dialog-mask' },
transition: {
enterFromClass: 'opacity-0 scale-75',
// 100ms is the duration of the transition in the dialog component
@@ -21,7 +24,24 @@
@hide="clearFilters"
>
+
+
+
+
diff --git a/src/components/searchbox/v2/NodeSearchCategoryTreeNode.vue b/src/components/searchbox/v2/NodeSearchCategoryTreeNode.vue
new file mode 100644
index 0000000000..7d23d57c32
--- /dev/null
+++ b/src/components/searchbox/v2/NodeSearchCategoryTreeNode.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/searchbox/v2/NodeSearchContent.test.ts b/src/components/searchbox/v2/NodeSearchContent.test.ts
new file mode 100644
index 0000000000..5559aa8c14
--- /dev/null
+++ b/src/components/searchbox/v2/NodeSearchContent.test.ts
@@ -0,0 +1,729 @@
+import type { VueWrapper } from '@vue/test-utils'
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+
+import NodeSearchCategorySidebar from '@/components/searchbox/v2/NodeSearchCategorySidebar.vue'
+import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
+import {
+ createMockNodeDef,
+ setupTestPinia,
+ testI18n
+} from '@/components/searchbox/v2/__test__/testUtils'
+import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
+import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
+import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
+import { NodeSourceType } from '@/types/nodeSource'
+
+vi.mock('@/platform/settings/settingStore', () => ({
+ useSettingStore: vi.fn(() => ({
+ get: vi.fn((key: string) => {
+ if (key === 'Comfy.NodeLibrary.Bookmarks.V2') return []
+ if (key === 'Comfy.NodeLibrary.BookmarksCustomization') return {}
+ return undefined
+ }),
+ set: vi.fn()
+ }))
+}))
+
+describe('NodeSearchContent', () => {
+ beforeEach(() => {
+ setupTestPinia()
+ vi.restoreAllMocks()
+ })
+
+ async function createWrapper(props = {}) {
+ const wrapper = mount(NodeSearchContent, {
+ props: { filters: [], ...props },
+ global: {
+ plugins: [testI18n],
+ stubs: {
+ NodeSearchListItem: {
+ template: '{{ nodeDef.display_name }}
',
+ props: [
+ 'nodeDef',
+ 'currentQuery',
+ 'showDescription',
+ 'showSourceBadge',
+ 'hideBookmarkIcon'
+ ]
+ }
+ }
+ }
+ })
+ await nextTick()
+ return wrapper
+ }
+
+ async function setupFavorites(
+ nodes: Parameters[0][]
+ ) {
+ useNodeDefStore().updateNodeDefs(nodes.map(createMockNodeDef))
+ vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(true)
+ const wrapper = await createWrapper()
+ await wrapper.find('[data-testid="category-favorites"]').trigger('click')
+ await nextTick()
+ return wrapper
+ }
+
+ function getResultItems(wrapper: VueWrapper) {
+ return wrapper.findAll('[data-testid="result-item"]')
+ }
+
+ function getNodeItems(wrapper: VueWrapper) {
+ return wrapper.findAll('.node-item')
+ }
+
+ describe('category selection', () => {
+ it('should show top nodes when Most relevant is selected', async () => {
+ useNodeDefStore().updateNodeDefs([
+ createMockNodeDef({
+ name: 'FrequentNode',
+ display_name: 'Frequent Node'
+ }),
+ createMockNodeDef({ name: 'RareNode', display_name: 'Rare Node' })
+ ])
+
+ vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
+ useNodeDefStore().nodeDefsByName['FrequentNode']
+ ])
+
+ const wrapper = await createWrapper()
+
+ const items = getNodeItems(wrapper)
+ expect(items).toHaveLength(1)
+ expect(items[0].text()).toContain('Frequent Node')
+ })
+
+ it('should show only bookmarked nodes when Favorites is selected', async () => {
+ useNodeDefStore().updateNodeDefs([
+ createMockNodeDef({
+ name: 'BookmarkedNode',
+ display_name: 'Bookmarked Node'
+ }),
+ createMockNodeDef({
+ name: 'RegularNode',
+ display_name: 'Regular Node'
+ })
+ ])
+ vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockImplementation(
+ (node: ComfyNodeDefImpl) => node.name === 'BookmarkedNode'
+ )
+
+ const wrapper = await createWrapper()
+ await wrapper.find('[data-testid="category-favorites"]').trigger('click')
+ await nextTick()
+
+ const items = getNodeItems(wrapper)
+ expect(items).toHaveLength(1)
+ expect(items[0].text()).toContain('Bookmarked')
+ })
+
+ it('should show empty state when no bookmarks exist', async () => {
+ useNodeDefStore().updateNodeDefs([
+ createMockNodeDef({ name: 'Node1', display_name: 'Node One' })
+ ])
+ vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
+
+ const wrapper = await createWrapper()
+ await wrapper.find('[data-testid="category-favorites"]').trigger('click')
+ await nextTick()
+
+ expect(wrapper.text()).toContain('No results')
+ })
+
+ it('should show only non-Core nodes when Custom is selected', async () => {
+ useNodeDefStore().updateNodeDefs([
+ createMockNodeDef({
+ name: 'CoreNode',
+ display_name: 'Core Node',
+ python_module: 'nodes'
+ }),
+ createMockNodeDef({
+ name: 'CustomNode',
+ display_name: 'Custom Node',
+ python_module: 'custom_nodes.my_extension'
+ })
+ ])
+ await nextTick()
+
+ expect(useNodeDefStore().nodeDefsByName['CoreNode'].nodeSource.type).toBe(
+ NodeSourceType.Core
+ )
+ expect(
+ useNodeDefStore().nodeDefsByName['CustomNode'].nodeSource.type
+ ).toBe(NodeSourceType.CustomNodes)
+
+ const wrapper = await createWrapper()
+ await wrapper.find('[data-testid="category-custom"]').trigger('click')
+ await nextTick()
+
+ const items = getNodeItems(wrapper)
+ expect(items).toHaveLength(1)
+ expect(items[0].text()).toContain('Custom Node')
+ })
+
+ it('should hide Essentials category when no essential nodes exist', async () => {
+ useNodeDefStore().updateNodeDefs([
+ createMockNodeDef({
+ name: 'RegularNode',
+ display_name: 'Regular Node'
+ })
+ ])
+
+ const wrapper = await createWrapper()
+ expect(wrapper.find('[data-testid="category-essentials"]').exists()).toBe(
+ false
+ )
+ })
+
+ it('should show only essential nodes when Essentials is selected', async () => {
+ useNodeDefStore().updateNodeDefs([
+ createMockNodeDef({
+ name: 'EssentialNode',
+ display_name: 'Essential Node',
+ essentials_category: 'basic'
+ }),
+ createMockNodeDef({
+ name: 'RegularNode',
+ display_name: 'Regular Node'
+ })
+ ])
+ await nextTick()
+
+ const wrapper = await createWrapper()
+ await wrapper.find('[data-testid="category-essentials"]').trigger('click')
+ await nextTick()
+
+ const items = getNodeItems(wrapper)
+ expect(items).toHaveLength(1)
+ expect(items[0].text()).toContain('Essential Node')
+ })
+
+ it('should include subcategory nodes when parent category is selected', async () => {
+ useNodeDefStore().updateNodeDefs([
+ createMockNodeDef({
+ name: 'KSampler',
+ display_name: 'KSampler',
+ category: 'sampling'
+ }),
+ createMockNodeDef({
+ name: 'LoadCheckpoint',
+ display_name: 'Load Checkpoint',
+ category: 'loaders'
+ }),
+ createMockNodeDef({
+ name: 'KSamplerAdvanced',
+ display_name: 'KSampler Advanced',
+ category: 'sampling/advanced'
+ })
+ ])
+
+ const wrapper = await createWrapper()
+ await wrapper.find('[data-testid="category-sampling"]').trigger('click')
+ await nextTick()
+
+ const texts = getNodeItems(wrapper).map((i) => i.text())
+ expect(texts).toHaveLength(2)
+ expect(texts).toContain('KSampler')
+ expect(texts).toContain('KSampler Advanced')
+ })
+ })
+
+ describe('search and category interaction', () => {
+ it('should override category to most-relevant when search query is active', async () => {
+ useNodeDefStore().updateNodeDefs([
+ createMockNodeDef({
+ name: 'KSampler',
+ display_name: 'KSampler',
+ category: 'sampling'
+ }),
+ createMockNodeDef({
+ name: 'LoadCheckpoint',
+ display_name: 'Load Checkpoint',
+ category: 'loaders'
+ })
+ ])
+
+ const wrapper = await createWrapper()
+ await wrapper.find('[data-testid="category-sampling"]').trigger('click')
+ await nextTick()
+
+ expect(getNodeItems(wrapper)).toHaveLength(1)
+
+ const input = wrapper.find('input[type="text"]')
+ await input.setValue('Load')
+ await nextTick()
+
+ const texts = getNodeItems(wrapper).map((i) => i.text())
+ expect(texts.some((t) => t.includes('Load Checkpoint'))).toBe(true)
+ })
+
+ it('should clear search query when category changes', async () => {
+ useNodeDefStore().updateNodeDefs([
+ createMockNodeDef({ name: 'TestNode', display_name: 'Test Node' })
+ ])
+
+ const wrapper = await createWrapper()
+
+ const input = wrapper.find('input[type="text"]')
+ await input.setValue('test query')
+ await nextTick()
+ expect((input.element as HTMLInputElement).value).toBe('test query')
+
+ await wrapper.find('[data-testid="category-favorites"]').trigger('click')
+ await nextTick()
+ expect((input.element as HTMLInputElement).value).toBe('')
+ })
+
+ it('should reset selected index when search query changes', async () => {
+ const wrapper = await setupFavorites([
+ { name: 'Node1', display_name: 'Node One' },
+ { name: 'Node2', display_name: 'Node Two' }
+ ])
+
+ const input = wrapper.find('input[type="text"]')
+ await input.trigger('keydown', { key: 'ArrowDown' })
+ await nextTick()
+ expect(getResultItems(wrapper)[1].attributes('aria-selected')).toBe(
+ 'true'
+ )
+
+ await input.setValue('Node')
+ await nextTick()
+ expect(getResultItems(wrapper)[0].attributes('aria-selected')).toBe(
+ 'true'
+ )
+ })
+
+ it('should reset selected index when category changes', async () => {
+ const wrapper = await setupFavorites([
+ { name: 'Node1', display_name: 'Node One' },
+ { name: 'Node2', display_name: 'Node Two' }
+ ])
+
+ const input = wrapper.find('input[type="text"]')
+ await input.trigger('keydown', { key: 'ArrowDown' })
+ await nextTick()
+
+ await wrapper
+ .find('[data-testid="category-most-relevant"]')
+ .trigger('click')
+ await nextTick()
+ await wrapper.find('[data-testid="category-favorites"]').trigger('click')
+ await nextTick()
+
+ expect(getResultItems(wrapper)[0].attributes('aria-selected')).toBe(
+ 'true'
+ )
+ })
+ })
+
+ describe('keyboard and mouse interaction', () => {
+ it('should navigate results with ArrowDown/ArrowUp and clamp to bounds', async () => {
+ const wrapper = await setupFavorites([
+ { name: 'Node1', display_name: 'Node One' },
+ { name: 'Node2', display_name: 'Node Two' },
+ { name: 'Node3', display_name: 'Node Three' }
+ ])
+
+ const input = wrapper.find('input[type="text"]')
+ const selectedIndex = () =>
+ getResultItems(wrapper).findIndex(
+ (r) => r.attributes('aria-selected') === 'true'
+ )
+
+ expect(selectedIndex()).toBe(0)
+
+ await input.trigger('keydown', { key: 'ArrowDown' })
+ await nextTick()
+ expect(selectedIndex()).toBe(1)
+
+ await input.trigger('keydown', { key: 'ArrowDown' })
+ await nextTick()
+ expect(selectedIndex()).toBe(2)
+
+ await input.trigger('keydown', { key: 'ArrowUp' })
+ await nextTick()
+ expect(selectedIndex()).toBe(1)
+
+ // Navigate to first, then try going above — should clamp
+ await input.trigger('keydown', { key: 'ArrowUp' })
+ await nextTick()
+ expect(selectedIndex()).toBe(0)
+
+ await input.trigger('keydown', { key: 'ArrowUp' })
+ await nextTick()
+ expect(selectedIndex()).toBe(0)
+ })
+
+ it('should select current result with Enter key', async () => {
+ const wrapper = await setupFavorites([
+ { name: 'TestNode', display_name: 'Test Node' }
+ ])
+
+ await wrapper
+ .find('input[type="text"]')
+ .trigger('keydown', { key: 'Enter' })
+ await nextTick()
+
+ expect(wrapper.emitted('addNode')).toBeTruthy()
+ expect(wrapper.emitted('addNode')![0][0]).toMatchObject({
+ name: 'TestNode'
+ })
+ })
+
+ it('should select item on hover', async () => {
+ const wrapper = await setupFavorites([
+ { name: 'Node1', display_name: 'Node One' },
+ { name: 'Node2', display_name: 'Node Two' }
+ ])
+
+ const results = getResultItems(wrapper)
+ await results[1].trigger('mouseenter')
+ await nextTick()
+
+ expect(results[1].attributes('aria-selected')).toBe('true')
+ })
+
+ it('should add node on click', async () => {
+ const wrapper = await setupFavorites([
+ { name: 'TestNode', display_name: 'Test Node' }
+ ])
+
+ await getResultItems(wrapper)[0].trigger('click')
+ await nextTick()
+
+ expect(wrapper.emitted('addNode')![0][0]).toMatchObject({
+ name: 'TestNode'
+ })
+ })
+ })
+
+ describe('hoverNode emission', () => {
+ it('should emit hoverNode with the currently selected node', async () => {
+ const wrapper = await setupFavorites([
+ { name: 'HoverNode', display_name: 'Hover Node' }
+ ])
+
+ const emitted = wrapper.emitted('hoverNode')!
+ expect(emitted[emitted.length - 1][0]).toMatchObject({
+ name: 'HoverNode'
+ })
+ })
+
+ it('should emit null hoverNode when no results', async () => {
+ const wrapper = await createWrapper()
+
+ vi.spyOn(useNodeBookmarkStore(), 'isBookmarked').mockReturnValue(false)
+ await wrapper.find('[data-testid="category-favorites"]').trigger('click')
+ await nextTick()
+
+ const emitted = wrapper.emitted('hoverNode')!
+ expect(emitted[emitted.length - 1][0]).toBeNull()
+ })
+ })
+
+ describe('filter integration', () => {
+ it('should display active filters in the input area', async () => {
+ useNodeDefStore().updateNodeDefs([
+ createMockNodeDef({
+ name: 'ImageNode',
+ display_name: 'Image Node',
+ input: { required: { image: ['IMAGE', {}] } }
+ })
+ ])
+
+ const wrapper = await createWrapper({
+ filters: [
+ {
+ filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
+ value: 'IMAGE'
+ }
+ ]
+ })
+
+ expect(
+ wrapper.findAll('[data-testid="filter-chip"]').length
+ ).toBeGreaterThan(0)
+ })
+ })
+
+ describe('chip removal', () => {
+ function createFilters(count: number) {
+ const types = ['IMAGE', 'LATENT', 'MODEL']
+ useNodeDefStore().updateNodeDefs(
+ types.slice(0, count).map((type) =>
+ createMockNodeDef({
+ name: `${type}Node`,
+ display_name: `${type} Node`,
+ input: {
+ required: { [type.toLowerCase()]: [type, {}] }
+ }
+ })
+ )
+ )
+ return types.slice(0, count).map((type) => ({
+ filterDef: useNodeDefStore().nodeSearchService.inputTypeFilter,
+ value: type
+ }))
+ }
+
+ it('should emit removeFilter on backspace', async () => {
+ const filters = createFilters(1)
+ const wrapper = await createWrapper({ filters })
+
+ const input = wrapper.find('input[type="text"]')
+ await input.trigger('keydown', { key: 'Backspace' })
+ await nextTick()
+ await input.trigger('keydown', { key: 'Backspace' })
+ await nextTick()
+
+ expect(wrapper.emitted('removeFilter')).toHaveLength(1)
+ expect(wrapper.emitted('removeFilter')![0][0]).toMatchObject({
+ value: 'IMAGE'
+ })
+ })
+
+ it('should not interact with chips when no filters exist', async () => {
+ const wrapper = await createWrapper({ filters: [] })
+
+ const input = wrapper.find('input[type="text"]')
+ await input.trigger('keydown', { key: 'Backspace' })
+ await nextTick()
+
+ expect(wrapper.emitted('removeFilter')).toBeUndefined()
+ })
+
+ it('should remove chip when clicking its delete button', async () => {
+ const filters = createFilters(1)
+ const wrapper = await createWrapper({ filters })
+
+ const deleteBtn = wrapper.find('[data-testid="chip-delete"]')
+ await deleteBtn.trigger('click')
+ await nextTick()
+
+ expect(wrapper.emitted('removeFilter')).toHaveLength(1)
+ expect(wrapper.emitted('removeFilter')![0][0]).toMatchObject({
+ value: 'IMAGE'
+ })
+ })
+ })
+
+ describe('filter selection mode', () => {
+ function setupNodesWithTypes() {
+ useNodeDefStore().updateNodeDefs([
+ createMockNodeDef({
+ name: 'ImageNode',
+ display_name: 'Image Node',
+ input: { required: { image: ['IMAGE', {}] } },
+ output: ['IMAGE']
+ }),
+ createMockNodeDef({
+ name: 'LatentNode',
+ display_name: 'Latent Node',
+ input: { required: { latent: ['LATENT', {}] } },
+ output: ['LATENT']
+ }),
+ createMockNodeDef({
+ name: 'ModelNode',
+ display_name: 'Model Node',
+ input: { required: { model: ['MODEL', {}] } },
+ output: ['MODEL']
+ })
+ ])
+ }
+
+ function findFilterBarButton(wrapper: VueWrapper, label: string) {
+ return wrapper
+ .findAll('button[aria-pressed]')
+ .find((b) => b.text() === label)
+ }
+
+ async function enterFilterMode(wrapper: VueWrapper) {
+ await findFilterBarButton(wrapper, 'Input')!.trigger('click')
+ await nextTick()
+ }
+
+ function getFilterOptions(wrapper: VueWrapper) {
+ return wrapper.findAll('[data-testid="filter-option"]')
+ }
+
+ function getFilterOptionTexts(wrapper: VueWrapper) {
+ return getFilterOptions(wrapper).map(
+ (o) =>
+ o
+ .findAll('span')[0]
+ ?.text()
+ .replace(/^[•·]\s*/, '')
+ .trim() ?? ''
+ )
+ }
+
+ function hasSidebar(wrapper: VueWrapper) {
+ return wrapper.findComponent(NodeSearchCategorySidebar).exists()
+ }
+
+ it('should enter filter mode when a filter chip is selected', async () => {
+ setupNodesWithTypes()
+ const wrapper = await createWrapper()
+
+ expect(hasSidebar(wrapper)).toBe(true)
+
+ await enterFilterMode(wrapper)
+
+ expect(hasSidebar(wrapper)).toBe(false)
+ expect(getFilterOptions(wrapper).length).toBeGreaterThan(0)
+ })
+
+ it('should show available filter options sorted alphabetically', async () => {
+ setupNodesWithTypes()
+ const wrapper = await createWrapper()
+ await enterFilterMode(wrapper)
+
+ const texts = getFilterOptionTexts(wrapper)
+ expect(texts).toContain('IMAGE')
+ expect(texts).toContain('LATENT')
+ expect(texts).toContain('MODEL')
+ expect(texts).toEqual([...texts].sort())
+ })
+
+ it('should filter options when typing in filter mode', async () => {
+ setupNodesWithTypes()
+ const wrapper = await createWrapper()
+ await enterFilterMode(wrapper)
+
+ await wrapper.find('input[type="text"]').setValue('IMAGE')
+ await nextTick()
+
+ const texts = getFilterOptionTexts(wrapper)
+ expect(texts).toContain('IMAGE')
+ expect(texts).not.toContain('MODEL')
+ })
+
+ it('should show no results when filter query has no matches', async () => {
+ setupNodesWithTypes()
+ const wrapper = await createWrapper()
+ await enterFilterMode(wrapper)
+
+ await wrapper.find('input[type="text"]').setValue('NONEXISTENT_TYPE')
+ await nextTick()
+
+ expect(wrapper.text()).toContain('No results')
+ })
+
+ it('should emit addFilter when a filter option is clicked', async () => {
+ setupNodesWithTypes()
+ const wrapper = await createWrapper()
+ await enterFilterMode(wrapper)
+
+ const imageOption = getFilterOptions(wrapper).find((o) =>
+ o.text().includes('IMAGE')
+ )
+ await imageOption!.trigger('click')
+ await nextTick()
+
+ expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
+ filterDef: expect.objectContaining({ id: 'input' }),
+ value: 'IMAGE'
+ })
+ })
+
+ it('should exit filter mode after applying a filter', async () => {
+ setupNodesWithTypes()
+ const wrapper = await createWrapper()
+ await enterFilterMode(wrapper)
+
+ await getFilterOptions(wrapper)[0].trigger('click')
+ await nextTick()
+ await nextTick()
+
+ expect(hasSidebar(wrapper)).toBe(true)
+ })
+
+ it('should emit addFilter when Enter is pressed on selected option', async () => {
+ setupNodesWithTypes()
+ const wrapper = await createWrapper()
+ await enterFilterMode(wrapper)
+
+ await wrapper
+ .find('input[type="text"]')
+ .trigger('keydown', { key: 'Enter' })
+ await nextTick()
+
+ expect(wrapper.emitted('addFilter')![0][0]).toMatchObject({
+ filterDef: expect.objectContaining({ id: 'input' }),
+ value: 'IMAGE'
+ })
+ })
+
+ it('should navigate filter options with ArrowDown/ArrowUp', async () => {
+ setupNodesWithTypes()
+ const wrapper = await createWrapper()
+ await enterFilterMode(wrapper)
+
+ const input = wrapper.find('input[type="text"]')
+
+ expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
+ 'true'
+ )
+
+ await input.trigger('keydown', { key: 'ArrowDown' })
+ await nextTick()
+ expect(getFilterOptions(wrapper)[1].attributes('aria-selected')).toBe(
+ 'true'
+ )
+
+ await input.trigger('keydown', { key: 'ArrowUp' })
+ await nextTick()
+ expect(getFilterOptions(wrapper)[0].attributes('aria-selected')).toBe(
+ 'true'
+ )
+ })
+
+ it('should toggle filter mode off when same chip is clicked again', async () => {
+ setupNodesWithTypes()
+ const wrapper = await createWrapper()
+ await enterFilterMode(wrapper)
+
+ await findFilterBarButton(wrapper, 'Input')!.trigger('click')
+ await nextTick()
+ await nextTick()
+
+ expect(hasSidebar(wrapper)).toBe(true)
+ })
+
+ it('should reset filter query when re-entering filter mode', async () => {
+ setupNodesWithTypes()
+ const wrapper = await createWrapper()
+ await enterFilterMode(wrapper)
+
+ const input = wrapper.find('input[type="text"]')
+ await input.setValue('IMAGE')
+ await nextTick()
+
+ await findFilterBarButton(wrapper, 'Input')!.trigger('click')
+ await nextTick()
+ await nextTick()
+
+ await enterFilterMode(wrapper)
+
+ expect((input.element as HTMLInputElement).value).toBe('')
+ })
+
+ it('should exit filter mode when cancel button is clicked', async () => {
+ setupNodesWithTypes()
+ const wrapper = await createWrapper()
+ await enterFilterMode(wrapper)
+
+ expect(hasSidebar(wrapper)).toBe(false)
+
+ const cancelBtn = wrapper.find('[data-testid="cancel-filter"]')
+ await cancelBtn.trigger('click')
+ await nextTick()
+ await nextTick()
+
+ expect(hasSidebar(wrapper)).toBe(true)
+ })
+ })
+})
diff --git a/src/components/searchbox/v2/NodeSearchContent.vue b/src/components/searchbox/v2/NodeSearchContent.vue
new file mode 100644
index 0000000000..49bc9230df
--- /dev/null
+++ b/src/components/searchbox/v2/NodeSearchContent.vue
@@ -0,0 +1,291 @@
+
+
+
+
+
+
+
+
+ {{ $t('g.filterBy') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('g.noResults') }}
+
+
+
+
+
+
+
diff --git a/src/components/searchbox/v2/NodeSearchFilterBar.test.ts b/src/components/searchbox/v2/NodeSearchFilterBar.test.ts
new file mode 100644
index 0000000000..982337a8f3
--- /dev/null
+++ b/src/components/searchbox/v2/NodeSearchFilterBar.test.ts
@@ -0,0 +1,80 @@
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+
+import NodeSearchFilterBar from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
+import {
+ createMockNodeDef,
+ setupTestPinia,
+ testI18n
+} from '@/components/searchbox/v2/__test__/testUtils'
+import { useNodeDefStore } from '@/stores/nodeDefStore'
+
+vi.mock('@/platform/settings/settingStore', () => ({
+ useSettingStore: vi.fn(() => ({
+ get: vi.fn(() => undefined),
+ set: vi.fn()
+ }))
+}))
+
+describe(NodeSearchFilterBar, () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ setupTestPinia()
+ useNodeDefStore().updateNodeDefs([
+ createMockNodeDef({
+ name: 'ImageNode',
+ input: { required: { image: ['IMAGE', {}] } },
+ output: ['IMAGE']
+ })
+ ])
+ })
+
+ async function createWrapper(props = {}) {
+ const wrapper = mount(NodeSearchFilterBar, {
+ props,
+ global: { plugins: [testI18n] }
+ })
+ await nextTick()
+ return wrapper
+ }
+
+ it('should render Input, Output, and Source filter chips', async () => {
+ const wrapper = await createWrapper()
+
+ const buttons = wrapper.findAll('button')
+ expect(buttons).toHaveLength(3)
+ expect(buttons[0].text()).toBe('Input')
+ expect(buttons[1].text()).toBe('Output')
+ expect(buttons[2].text()).toBe('Source')
+ })
+
+ it('should mark active chip as pressed when activeChipKey matches', async () => {
+ const wrapper = await createWrapper({ activeChipKey: 'input' })
+
+ const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
+ expect(inputBtn?.attributes('aria-pressed')).toBe('true')
+ })
+
+ it('should not mark chips as pressed when activeChipKey does not match', async () => {
+ const wrapper = await createWrapper({ activeChipKey: null })
+
+ wrapper.findAll('button').forEach((btn) => {
+ expect(btn.attributes('aria-pressed')).toBe('false')
+ })
+ })
+
+ it('should emit selectChip with chip data when clicked', async () => {
+ const wrapper = await createWrapper()
+
+ const inputBtn = wrapper.findAll('button').find((b) => b.text() === 'Input')
+ await inputBtn?.trigger('click')
+
+ const emitted = wrapper.emitted('selectChip')!
+ expect(emitted[0][0]).toMatchObject({
+ key: 'input',
+ label: 'Input',
+ filter: expect.anything()
+ })
+ })
+})
diff --git a/src/components/searchbox/v2/NodeSearchFilterBar.vue b/src/components/searchbox/v2/NodeSearchFilterBar.vue
new file mode 100644
index 0000000000..21c995808c
--- /dev/null
+++ b/src/components/searchbox/v2/NodeSearchFilterBar.vue
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/searchbox/v2/NodeSearchFilterPanel.vue b/src/components/searchbox/v2/NodeSearchFilterPanel.vue
new file mode 100644
index 0000000000..8f8006d3d9
--- /dev/null
+++ b/src/components/searchbox/v2/NodeSearchFilterPanel.vue
@@ -0,0 +1,90 @@
+
+
+
+
+ •
+ {{ option }}
+
+
+
+ {{ $t('g.noResults') }}
+
+
+
+
+
diff --git a/src/components/searchbox/v2/NodeSearchInput.test.ts b/src/components/searchbox/v2/NodeSearchInput.test.ts
new file mode 100644
index 0000000000..5b5a187af1
--- /dev/null
+++ b/src/components/searchbox/v2/NodeSearchInput.test.ts
@@ -0,0 +1,161 @@
+import { mount } from '@vue/test-utils'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { FilterChip } from '@/components/searchbox/v2/NodeSearchFilterBar.vue'
+import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
+import {
+ setupTestPinia,
+ testI18n
+} from '@/components/searchbox/v2/__test__/testUtils'
+import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
+import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
+
+vi.mock('@/utils/litegraphUtil', () => ({
+ getLinkTypeColor: vi.fn((type: string) =>
+ type === 'IMAGE' ? '#64b5f6' : undefined
+ )
+}))
+
+vi.mock('@/platform/settings/settingStore', () => ({
+ useSettingStore: vi.fn(() => ({
+ get: vi.fn(),
+ set: vi.fn()
+ }))
+}))
+
+function createFilter(
+ id: string,
+ value: string
+): FuseFilterWithValue {
+ return {
+ filterDef: {
+ id,
+ matches: vi.fn(() => true)
+ } as unknown as FuseFilter,
+ value
+ }
+}
+
+function createActiveFilter(label: string): FilterChip {
+ return {
+ key: label.toLowerCase(),
+ label,
+ filter: {
+ id: label.toLowerCase(),
+ matches: vi.fn(() => true)
+ } as unknown as FuseFilter
+ }
+}
+
+describe('NodeSearchInput', () => {
+ beforeEach(() => {
+ setupTestPinia()
+ vi.restoreAllMocks()
+ })
+
+ function createWrapper(
+ props: Partial<{
+ filters: FuseFilterWithValue[]
+ activeFilter: FilterChip | null
+ searchQuery: string
+ filterQuery: string
+ }> = {}
+ ) {
+ return mount(NodeSearchInput, {
+ props: {
+ filters: [],
+ activeFilter: null,
+ searchQuery: '',
+ filterQuery: '',
+ ...props
+ },
+ global: { plugins: [testI18n] }
+ })
+ }
+
+ it('should route input to searchQuery when no active filter', async () => {
+ const wrapper = createWrapper()
+ await wrapper.find('input').setValue('test search')
+
+ expect(wrapper.emitted('update:searchQuery')![0]).toEqual(['test search'])
+ })
+
+ it('should route input to filterQuery when active filter is set', async () => {
+ const wrapper = createWrapper({
+ activeFilter: createActiveFilter('Input')
+ })
+ await wrapper.find('input').setValue('IMAGE')
+
+ expect(wrapper.emitted('update:filterQuery')![0]).toEqual(['IMAGE'])
+ expect(wrapper.emitted('update:searchQuery')).toBeUndefined()
+ })
+
+ it('should show filter label placeholder when active filter is set', () => {
+ const wrapper = createWrapper({
+ activeFilter: createActiveFilter('Input')
+ })
+
+ expect(
+ (wrapper.find('input').element as HTMLInputElement).placeholder
+ ).toContain('input')
+ })
+
+ it('should show add node placeholder when no active filter', () => {
+ const wrapper = createWrapper()
+
+ expect(
+ (wrapper.find('input').element as HTMLInputElement).placeholder
+ ).toContain('Add a node')
+ })
+
+ it('should hide filter chips when active filter is set', () => {
+ const wrapper = createWrapper({
+ filters: [createFilter('input', 'IMAGE')],
+ activeFilter: createActiveFilter('Input')
+ })
+
+ expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(0)
+ })
+
+ it('should show filter chips when no active filter', () => {
+ const wrapper = createWrapper({
+ filters: [createFilter('input', 'IMAGE')]
+ })
+
+ expect(wrapper.findAll('[data-testid="filter-chip"]')).toHaveLength(1)
+ })
+
+ it('should emit cancelFilter when cancel button is clicked', async () => {
+ const wrapper = createWrapper({
+ activeFilter: createActiveFilter('Input')
+ })
+
+ await wrapper.find('[data-testid="cancel-filter"]').trigger('click')
+
+ expect(wrapper.emitted('cancelFilter')).toHaveLength(1)
+ })
+
+ it('should emit selectCurrent on Enter', async () => {
+ const wrapper = createWrapper()
+
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
+
+ expect(wrapper.emitted('selectCurrent')).toHaveLength(1)
+ })
+
+ it('should emit navigateDown on ArrowDown', async () => {
+ const wrapper = createWrapper()
+
+ await wrapper.find('input').trigger('keydown', { key: 'ArrowDown' })
+
+ expect(wrapper.emitted('navigateDown')).toHaveLength(1)
+ })
+
+ it('should emit navigateUp on ArrowUp', async () => {
+ const wrapper = createWrapper()
+
+ await wrapper.find('input').trigger('keydown', { key: 'ArrowUp' })
+
+ expect(wrapper.emitted('navigateUp')).toHaveLength(1)
+ })
+})
diff --git a/src/components/searchbox/v2/NodeSearchInput.vue b/src/components/searchbox/v2/NodeSearchInput.vue
new file mode 100644
index 0000000000..dd144989f5
--- /dev/null
+++ b/src/components/searchbox/v2/NodeSearchInput.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+ {{ activeFilter.label }}:
+
+
+
+
+
+
+ {{ t(`g.${filter.filterDef.id}`) }}:
+
+
+ •
+
+ {{ filter.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/searchbox/v2/NodeSearchListItem.vue b/src/components/searchbox/v2/NodeSearchListItem.vue
new file mode 100644
index 0000000000..14495fbce1
--- /dev/null
+++ b/src/components/searchbox/v2/NodeSearchListItem.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ nodeDef.nodeSource.displayText }}
+
+
+ {{ nodeDef.description }}
+
+
+
+ {{ nodeDef.category.replaceAll('/', ' > ') }}
+
+
+
+
+ {{ $t('g.deprecated') }}
+
+
+ {{ $t('g.experimental') }}
+
+
+ {{ $t('g.devOnly') }}
+
+
+ {{ formatNumberWithSuffix(nodeFrequency, { roundToInt: true }) }}
+
+
+ {{ nodeDef.nodeSource.displayText }}
+
+
+
+
+
+
+
+
diff --git a/src/components/searchbox/v2/__test__/testUtils.ts b/src/components/searchbox/v2/__test__/testUtils.ts
new file mode 100644
index 0000000000..23eb2a6424
--- /dev/null
+++ b/src/components/searchbox/v2/__test__/testUtils.ts
@@ -0,0 +1,52 @@
+import { createTestingPinia } from '@pinia/testing'
+import { setActivePinia } from 'pinia'
+import { createI18n } from 'vue-i18n'
+
+import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
+
+export function createMockNodeDef(
+ overrides: Partial = {}
+): ComfyNodeDef {
+ return {
+ name: 'TestNode',
+ display_name: 'Test Node',
+ category: 'test',
+ python_module: 'nodes',
+ description: 'Test description',
+ input: {},
+ output: [],
+ output_is_list: [],
+ output_name: [],
+ output_node: false,
+ deprecated: false,
+ experimental: false,
+ ...overrides
+ }
+}
+
+export function setupTestPinia() {
+ setActivePinia(createTestingPinia({ stubActions: false }))
+}
+
+export const testI18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: {
+ en: {
+ g: {
+ addNode: 'Add a node...',
+ filterBy: 'Filter by:',
+ mostRelevant: 'Most relevant',
+ favorites: 'Favorites',
+ essentials: 'Essentials',
+ custom: 'Custom',
+ noResults: 'No results',
+ filterByType: 'Filter by {type}...',
+ input: 'Input',
+ output: 'Output',
+ source: 'Source',
+ search: 'Search'
+ }
+ }
+ }
+})
diff --git a/src/components/sidebar/tabs/NodeLibrarySidebarTabV2.test.ts b/src/components/sidebar/tabs/NodeLibrarySidebarTabV2.test.ts
new file mode 100644
index 0000000000..1bf30a126b
--- /dev/null
+++ b/src/components/sidebar/tabs/NodeLibrarySidebarTabV2.test.ts
@@ -0,0 +1,132 @@
+import { mount } from '@vue/test-utils'
+import { createTestingPinia } from '@pinia/testing'
+import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
+import { ref } from 'vue'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { createI18n } from 'vue-i18n'
+
+import NodeLibrarySidebarTabV2 from './NodeLibrarySidebarTabV2.vue'
+
+vi.mock('@vueuse/core', async () => {
+ const actual = await vi.importActual('@vueuse/core')
+ return {
+ ...actual,
+ useLocalStorage: vi.fn((_key: string, defaultValue: unknown) =>
+ ref(defaultValue)
+ )
+ }
+})
+
+vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
+ useNodeDragToCanvas: () => ({
+ isDragging: { value: false },
+ draggedNode: { value: null },
+ cursorPosition: { value: { x: 0, y: 0 } },
+ startDrag: vi.fn(),
+ cancelDrag: vi.fn(),
+ setupGlobalListeners: vi.fn(),
+ cleanupGlobalListeners: vi.fn()
+ })
+}))
+
+vi.mock('@/services/nodeOrganizationService', () => ({
+ DEFAULT_TAB_ID: 'essentials',
+ DEFAULT_SORTING_ID: 'alphabetical',
+ nodeOrganizationService: {
+ organizeNodesByTab: vi.fn(() => []),
+ getSortingStrategies: vi.fn(() => [])
+ }
+}))
+
+vi.mock('./nodeLibrary/AllNodesPanel.vue', () => ({
+ default: {
+ name: 'AllNodesPanel',
+ template: '
',
+ props: ['sections', 'expandedKeys', 'fillNodeInfo']
+ }
+}))
+
+vi.mock('./nodeLibrary/CustomNodesPanel.vue', () => ({
+ default: {
+ name: 'CustomNodesPanel',
+ template: '
',
+ props: ['sections', 'expandedKeys']
+ }
+}))
+
+vi.mock('./nodeLibrary/EssentialNodesPanel.vue', () => ({
+ default: {
+ name: 'EssentialNodesPanel',
+ template: '
',
+ props: ['root', 'expandedKeys']
+ }
+}))
+
+vi.mock('./nodeLibrary/NodeDragPreview.vue', () => ({
+ default: {
+ name: 'NodeDragPreview',
+ template: ''
+ }
+}))
+
+vi.mock('@/components/common/SearchBoxV2.vue', () => ({
+ default: {
+ name: 'SearchBox',
+ template: '',
+ props: ['modelValue', 'placeholder'],
+ setup() {
+ return { focus: vi.fn() }
+ },
+ expose: ['focus']
+ }
+}))
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: { en: {} }
+})
+
+describe('NodeLibrarySidebarTabV2', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ function mountComponent() {
+ return mount(NodeLibrarySidebarTabV2, {
+ global: {
+ plugins: [createTestingPinia({ stubActions: false }), i18n],
+ components: {
+ TabsRoot,
+ TabsList,
+ TabsTrigger,
+ TabsContent
+ },
+ stubs: {
+ teleport: true
+ }
+ }
+ })
+ }
+
+ it('should render with tabs', () => {
+ const wrapper = mountComponent()
+
+ const triggers = wrapper.findAllComponents(TabsTrigger)
+ expect(triggers.length).toBe(3)
+ })
+
+ it('should render search box', () => {
+ const wrapper = mountComponent()
+
+ expect(wrapper.find('[data-testid="search-box"]').exists()).toBe(true)
+ })
+
+ it('should render only the selected panel', () => {
+ const wrapper = mountComponent()
+
+ expect(wrapper.find('[data-testid="essential-panel"]').exists()).toBe(true)
+ expect(wrapper.find('[data-testid="all-panel"]').exists()).toBe(false)
+ expect(wrapper.find('[data-testid="custom-panel"]').exists()).toBe(false)
+ })
+})
diff --git a/src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue b/src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue
new file mode 100644
index 0000000000..c405b68c5d
--- /dev/null
+++ b/src/components/sidebar/tabs/NodeLibrarySidebarTabV2.vue
@@ -0,0 +1,348 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t(option.label) }}
+
+
+
+
+
+
+
+
+
+
+ {{ tab.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/sidebar/tabs/SidebarTabTemplate.vue b/src/components/sidebar/tabs/SidebarTabTemplate.vue
index 9194045180..4d0414e92a 100644
--- a/src/components/sidebar/tabs/SidebarTabTemplate.vue
+++ b/src/components/sidebar/tabs/SidebarTabTemplate.vue
@@ -1,5 +1,6 @@
+import type { InjectionKey, Ref } from 'vue'
+
+export const SidebarContainerKey: InjectionKey[> =
+ Symbol('SidebarContainer')
+
+
]