Decouple node help between sidebar and right panel (#8110)

## Summary

When the node library is open and you click on the node toolbar info
button, this causes the node library info panel & right panel node info
to show the same details.

## Changes

- Extract useNodeHelpContent composable so NodeHelpContent fetches its
own content, allowing multiple panels to show help independently
- Remove sync behavior from NodeHelpPage that caused left sidebar to
change when selecting different graph nodes since we want to prioritise
right panel for this behavior
- Add telemetry tracking for node library help button to identify how
frequently this is used

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8110-Decouple-node-help-between-sidebar-and-right-panel-2ea6d73d365081a9b3afd25aa51b34bd)
by [Unito](https://www.unito.io)
This commit is contained in:
pythongosssss
2026-01-16 21:13:23 -08:00
committed by GitHub
parent 3fcebe758b
commit 3bbae61763
10 changed files with 487 additions and 643 deletions

View File

@@ -6,67 +6,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import InfoButton from '@/components/graph/selectionToolbox/InfoButton.vue'
// NOTE: The component import must come after mocks so they take effect.
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import Button from '@/components/ui/button/Button.vue'
const mockLGraphNode = {
type: 'TestNode',
title: 'Test Node'
}
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => true)
const { openPanelMock } = vi.hoisted(() => ({
openPanelMock: vi.fn()
}))
vi.mock('@/composables/sidebarTabs/useNodeLibrarySidebarTab', () => ({
useNodeLibrarySidebarTab: () => ({
id: 'node-library'
})
}))
const openHelpMock = vi.fn()
const closeHelpMock = vi.fn()
const nodeHelpState: { currentHelpNode: any } = { currentHelpNode: null }
vi.mock('@/stores/workspace/nodeHelpStore', () => ({
useNodeHelpStore: () => ({
openHelp: (def: any) => {
nodeHelpState.currentHelpNode = def
openHelpMock(def)
},
closeHelp: () => {
nodeHelpState.currentHelpNode = null
closeHelpMock()
},
get currentHelpNode() {
return nodeHelpState.currentHelpNode
},
get isHelpOpen() {
return nodeHelpState.currentHelpNode !== null
}
})
}))
const toggleSidebarTabMock = vi.fn((id: string) => {
sidebarState.activeSidebarTabId =
sidebarState.activeSidebarTabId === id ? null : id
})
const sidebarState: { activeSidebarTabId: string | null } = {
activeSidebarTabId: 'other-tab'
}
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => ({
get activeSidebarTabId() {
return sidebarState.activeSidebarTabId
},
toggleSidebarTab: toggleSidebarTabMock
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({
openPanel: openPanelMock
})
}))
describe('InfoButton', () => {
let canvasStore: ReturnType<typeof useCanvasStore>
let nodeDefStore: ReturnType<typeof useNodeDefStore>
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -81,9 +33,6 @@ describe('InfoButton', () => {
beforeEach(() => {
setActivePinia(createPinia())
canvasStore = useCanvasStore()
nodeDefStore = useNodeDefStore()
vi.clearAllMocks()
})
@@ -92,58 +41,15 @@ describe('InfoButton', () => {
global: {
plugins: [i18n, PrimeVue],
directives: { tooltip: Tooltip },
stubs: {
'i-lucide:info': true,
Button: {
template:
'<button class="help-button" severity="secondary"><slot /></button>',
props: ['severity', 'text', 'class'],
emits: ['click']
}
}
components: { Button }
}
})
}
it('should handle click without errors', async () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
it('should open the info panel on click', async () => {
const wrapper = mountComponent()
const button = wrapper.find('button')
const button = wrapper.find('[data-testid="info-button"]')
await button.trigger('click')
expect(button.exists()).toBe(true)
})
it('should have correct CSS classes', () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.classes()).toContain('help-button')
expect(button.attributes('severity')).toBe('secondary')
})
it('should have correct tooltip', () => {
const mockNodeDef = {
nodePath: 'test/node',
display_name: 'Test Node'
}
canvasStore.selectedItems = [mockLGraphNode] as any
vi.spyOn(nodeDefStore, 'fromLGraphNode').mockReturnValue(mockNodeDef as any)
const wrapper = mountComponent()
const button = wrapper.find('button')
expect(button.exists()).toBe(true)
expect(openPanelMock).toHaveBeenCalledWith('info')
})
})

View File

@@ -70,17 +70,17 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import ProgressSpinner from 'primevue/progressspinner'
import { computed } from 'vue'
import { useNodeHelpContent } from '@/composables/useNodeHelpContent'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
const { node } = defineProps<{
node: ComfyNodeDefImpl
}>()
const nodeHelpStore = useNodeHelpStore()
const { renderedHelpHtml, isLoading, error } = storeToRefs(nodeHelpStore)
const { renderedHelpHtml, isLoading, error } = useNodeHelpContent(() => node)
const inputList = computed(() =>
Object.values(node.inputs).map((spec) => ({

View File

@@ -1,11 +1,9 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
const { nodes } = defineProps<{
nodes: LGraphNode[]
@@ -13,20 +11,10 @@ const { nodes } = defineProps<{
const node = computed(() => nodes[0])
const nodeDefStore = useNodeDefStore()
const nodeHelpStore = useNodeHelpStore()
const nodeInfo = computed(() => {
return nodeDefStore.fromLGraphNode(node.value)
})
// Open node help when the selected node changes
whenever(
nodeInfo,
(info) => {
nodeHelpStore.openHelp(info)
},
{ immediate: true }
)
</script>
<template>

View File

@@ -1,100 +0,0 @@
import { flushPromises, mount } from '@vue/test-utils'
import { computed, ref } from 'vue'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import NodeHelpPage from '@/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
vi.mock('@/composables/graph/useSelectionState')
vi.mock('@/stores/workspace/nodeHelpStore')
const baseNode = {
nodePath: 'NodeA',
display_name: 'Node A',
description: '',
inputs: {},
outputs: []
}
describe('NodeHelpPage', () => {
const selection = ref<any | null>(null)
let openHelp: ReturnType<typeof vi.fn>
const mountPage = () =>
mount(NodeHelpPage, {
props: { node: baseNode as any },
global: {
mocks: {
$t: (key: string) => key
},
stubs: {
ProgressSpinner: true,
Button: true
}
}
})
beforeEach(() => {
vi.resetAllMocks()
selection.value = null
openHelp = vi.fn()
vi.mocked(useSelectionState).mockReturnValue({
nodeDef: computed(() => selection.value)
} as any)
vi.mocked(useNodeHelpStore).mockReturnValue({
renderedHelpHtml: ref('<p>help</p>'),
isLoading: ref(false),
error: ref(null),
isHelpOpen: true,
currentHelpNode: { nodePath: 'NodeA' },
openHelp,
closeHelp: vi.fn()
} as any)
})
test('opens help for a newly selected node while help is open', async () => {
const wrapper = mountPage()
selection.value = { nodePath: 'NodeB' }
await flushPromises()
expect(openHelp).toHaveBeenCalledWith({ nodePath: 'NodeB' })
wrapper.unmount()
})
test('does not reopen help when the same node stays selected', async () => {
const wrapper = mountPage()
selection.value = { nodePath: 'NodeA' }
await flushPromises()
expect(openHelp).not.toHaveBeenCalled()
wrapper.unmount()
})
test('does not react to selection when help is closed', async () => {
vi.mocked(useNodeHelpStore).mockReturnValueOnce({
renderedHelpHtml: ref('<p>help</p>'),
isLoading: ref(false),
error: ref(null),
isHelpOpen: false,
currentHelpNode: null,
openHelp,
closeHelp: vi.fn()
} as any)
const wrapper = mountPage()
selection.value = { nodePath: 'NodeB' }
await flushPromises()
expect(openHelp).not.toHaveBeenCalled()
wrapper.unmount()
})
})

View File

@@ -21,32 +21,13 @@
</template>
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { computed } from 'vue'
import NodeHelpContent from '@/components/node/NodeHelpContent.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSelectionState } from '@/composables/graph/useSelectionState'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
defineEmits<{
(e: 'close'): void
}>()
const nodeHelpStore = useNodeHelpStore()
const { nodeDef } = useSelectionState()
const activeHelpDef = computed(() =>
nodeHelpStore.isHelpOpen ? nodeDef.value : null
)
// Keep the open help page synced with the current selection while help is open.
whenever(activeHelpDef, (def) => {
const currentHelpNode = nodeHelpStore.currentHelpNode
if (currentHelpNode?.nodePath === def.nodePath) return
nodeHelpStore.openHelp(def)
})
</script>

View File

@@ -57,7 +57,7 @@
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('g.learnMore')"
@click.stop="props.openNodeHelp(nodeDef)"
@click.stop="onHelpClick"
>
<i class="pi pi-question size-3.5" />
</Button>
@@ -85,6 +85,7 @@ import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
@@ -112,6 +113,13 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
const toggleBookmark = async () => {
await nodeBookmarkStore.toggleBookmark(nodeDef.value)
}
const onHelpClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'node_library_help_button'
})
props.openNodeHelp(nodeDef.value)
}
const editBlueprint = async () => {
if (!props.node.data)
throw new Error(