Compare commits

..

5 Commits

Author SHA1 Message Date
dante01yoon
82a0b59367 refactor: migrate node tooltips from PrimeVue to BaseTooltip
Replace PrimeVue v-tooltip directive with Reka UI BaseTooltip component
in NodeHeader, InputSlot, OutputSlot, and NodeWidgets. Remove
PrimeVue-specific tooltip config (createTooltipConfig, hideTooltipsGlobally,
tooltipsTemporarilyDisabled, global event listeners) from useNodeTooltips
composable, keeping only text-resolution functions and tooltipsEnabled.
Add tooltipDelay computed from LiteGraph.Node.TooltipDelay setting.
2026-03-24 10:59:32 +09:00
dante01yoon
f16214a719 refactor: migrate buildTooltipConfig consumers to BaseTooltip
Replace all PrimeVue v-tooltip directive usages that relied on
buildTooltipConfig with the new Reka UI BaseTooltip component.
Delete the now-unused useTooltipConfig composable.
2026-03-22 08:13:52 +09:00
dante01yoon
ae07b4d3f8 refactor: align tooltip variants with Figma design system
Replace variant prop (default/small/large) with size prop (small/large)
to match Figma component properties. Remove default variant that was
replicating PrimeVue Aura styling. Extract shared styles into cva base.
2026-03-21 21:36:22 +09:00
dante01yoon
2a0daf20da refactor: remove Storybook stories from foundation PR 2026-03-21 21:30:39 +09:00
dante01yoon
356a291d09 feat: add BaseTooltip component using Reka UI
Add foundational tooltip component with three variants (default, small,
large), four positioning sides, and configurable delay/disabled state.
Wrap App.vue in TooltipProvider for app-wide tooltip support. Include
Storybook stories for all variants, sides, disabled, and long text.
2026-03-21 20:02:36 +09:00
68 changed files with 1109 additions and 2214 deletions

View File

@@ -15,7 +15,7 @@ type ValidationState = InstallValidation['basePath']
type IndexedUpdate = InstallValidation & Record<string, ValidationState>
/** State of a maintenance task, managed by the maintenance task store. */
class MaintenanceTaskRunner {
export class MaintenanceTaskRunner {
constructor(readonly task: MaintenanceTask) {}
private _state?: MaintenanceTaskState

View File

@@ -281,14 +281,6 @@ export class NodeReference {
getType(): Promise<string> {
return this.getProperty('type')
}
async centerOnNode(): Promise<void> {
await this.comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found`)
window.app!.canvas.centerOnNode(node)
}, this.id)
await this.comfyPage.nextFrame()
}
async getPosition(): Promise<Position> {
const pos = await this.comfyPage.canvasOps.convertOffsetToCanvas(
await this.getProperty<[number, number]>('pos')

View File

@@ -30,18 +30,10 @@ async function setupSubgraphBuilder(comfyPage: ComfyPage) {
await appMode.enterBuilder()
await appMode.goToInputs()
// Reset zoom to 1 and center on the subgraph node so click coords are accurate
await comfyPage.canvasOps.setScale(1)
await subgraphNode.centerOnNode()
// Click the promoted seed widget on the canvas to select it
const seedWidgetRef = await subgraphNode.getWidget(0)
const seedPos = await seedWidgetRef.getPosition()
const titleHeight = await page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
await page.mouse.click(seedPos.x, seedPos.y)
await comfyPage.nextFrame()
// Select an output node
@@ -56,15 +48,9 @@ async function setupSubgraphBuilder(comfyPage: ComfyPage) {
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
await saveImageRef.centerOnNode()
// Node is centered on screen, so click the canvas center
const canvasBox = await page.locator('#graph-canvas').boundingBox()
if (!canvasBox) throw new Error('Canvas not found')
await page.mouse.click(
canvasBox.x + canvasBox.width / 2,
canvasBox.y + canvasBox.height / 2
)
const saveImagePos = await saveImageRef.getPosition()
// Click left edge — the right side is hidden by the builder panel
await page.mouse.click(saveImagePos.x + 10, saveImagePos.y - 10)
await comfyPage.nextFrame()
return subgraphNode
@@ -94,10 +80,6 @@ test.describe('App mode widget rename', { tag: ['@ui', '@subgraph'] }, () => {
}
})
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting(
'Comfy.AppBuilder.VueNodeSwitchDismissed',
true
)
})
test('Rename from builder input-select sidebar via menu', async ({

View File

@@ -5,7 +5,6 @@ import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
import { importX } from 'eslint-plugin-import-x'
import oxlint from 'eslint-plugin-oxlint'
import testingLibrary from 'eslint-plugin-testing-library'
// eslint-config-prettier disables ESLint rules that conflict with formatters (oxfmt)
import eslintConfigPrettier from 'eslint-config-prettier'
import { configs as storybookConfigs } from 'eslint-plugin-storybook'
@@ -272,20 +271,6 @@ export default defineConfig([
]
}
},
{
files: ['**/*.test.ts'],
plugins: { 'testing-library': testingLibrary },
rules: {
'testing-library/prefer-screen-queries': 'error',
'testing-library/no-container': 'error',
'testing-library/no-node-access': 'error',
'testing-library/no-wait-for-multiple-assertions': 'error',
'testing-library/prefer-find-by': 'error',
'testing-library/prefer-presence-queries': 'error',
'testing-library/prefer-user-event': 'error',
'testing-library/no-debugging-utils': 'error'
}
},
{
files: ['scripts/**/*.js'],
languageOptions: {

View File

@@ -6,6 +6,7 @@ const config: KnipConfig = {
entry: [
'{build,scripts}/**/*.{js,ts}',
'src/assets/css/style.css',
'src/main.ts',
'src/scripts/ui/menu/index.ts',
'src/types/index.ts',
'src/storybook/mocks/**/*.ts'
@@ -13,23 +14,25 @@ const config: KnipConfig = {
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
},
'apps/desktop-ui': {
entry: ['src/i18n.ts'],
entry: ['src/main.ts', 'src/i18n.ts'],
project: ['src/**/*.{js,ts,vue}']
},
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
'packages/shared-frontend-utils': {
project: ['src/**/*.{js,ts}']
project: ['src/**/*.{js,ts}'],
entry: ['src/formatUtil.ts', 'src/networkUtil.ts']
},
'packages/registry-types': {
project: ['src/**/*.{js,ts}']
},
'packages/ingest-types': {
project: ['src/**/*.{js,ts}']
project: ['src/**/*.{js,ts}'],
entry: ['src/index.ts']
}
},
ignoreBinaries: ['python3'],
ignoreBinaries: ['python3', 'gh', 'generate'],
ignoreDependencies: [
// Weird importmap things
'@iconify-json/lucide',
@@ -37,12 +40,19 @@ const config: KnipConfig = {
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons'
'@primevue/icons',
// Used by lucideStrokePlugin.js (CSS @plugin)
'@iconify/utils'
],
ignore: [
// Auto generated API types
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
'packages/registry-types/src/comfyRegistryTypes.ts',
'packages/ingest-types/src/types.gen.ts',
'packages/ingest-types/src/zod.gen.ts',
'packages/ingest-types/openapi-ts.config.ts',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Used by stacked PR (feat/glsl-live-preview)
'src/renderer/glsl/useGLSLRenderer.ts',
// Workflow files contain license names that knip misinterprets as binaries
@@ -50,8 +60,17 @@ const config: KnipConfig = {
// Pending integration in stacked PR
'src/components/sidebar/tabs/nodeLibrary/CustomNodesPanel.vue',
// Agent review check config, not part of the build
'.agents/checks/eslint.strict.config.js'
'.agents/checks/eslint.strict.config.js',
// Loaded via @plugin directive in CSS, not detected by knip
'packages/design-system/src/css/lucideStrokePlugin.js'
],
compilers: {
// https://github.com/webpro-nl/knip/issues/1008#issuecomment-3207756199
css: (text: string) =>
[...text.replaceAll('plugin', 'import').matchAll(/(?<=@)import[^;]+/g)]
.map((match) => match[0].replace(/url\(['"]?([^'"()]+)['"]?\)/, '$1'))
.join('\n')
},
vite: {
config: ['vite?(.*).config.mts']
},

View File

@@ -11,7 +11,7 @@ export default {
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
'./**/*.{ts,tsx,vue,mts,json,yaml}': (stagedFiles: string[]) => {
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => {
const commands = [...formatAndEslint(stagedFiles), 'pnpm typecheck']
const hasBrowserTestsChanges = stagedFiles

View File

@@ -36,6 +36,5 @@
"targetName": "e2e"
}
}
],
"analytics": false
]
}

View File

@@ -135,9 +135,6 @@
"@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:",
"@tailwindcss/vite": "catalog:",
"@testing-library/jest-dom": "catalog:",
"@testing-library/user-event": "catalog:",
"@testing-library/vue": "catalog:",
"@types/fs-extra": "catalog:",
"@types/jsdom": "catalog:",
"@types/node": "catalog:",
@@ -156,7 +153,6 @@
"eslint-plugin-import-x": "catalog:",
"eslint-plugin-oxlint": "catalog:",
"eslint-plugin-storybook": "catalog:",
"eslint-plugin-testing-library": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
"eslint-plugin-vue": "catalog:",
"fast-check": "catalog:",
@@ -181,7 +177,9 @@
"storybook": "catalog:",
"stylelint": "catalog:",
"tailwindcss": "catalog:",
"tailwindcss-primeui": "catalog:",
"tsx": "catalog:",
"tw-animate-css": "catalog:",
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"unplugin-icons": "catalog:",

View File

@@ -12,9 +12,7 @@
"dependencies": {
"@iconify-json/lucide": "catalog:",
"@iconify/tailwind4": "catalog:",
"@iconify/utils": "catalog:",
"tailwindcss-primeui": "catalog:",
"tw-animate-css": "catalog:"
"@iconify/utils": "catalog:"
},
"devDependencies": {
"tailwindcss": "catalog:",

View File

@@ -15,7 +15,7 @@
@plugin "./lucideStrokePlugin.js";
/* Safelist dynamic comfy icons for node library folders */
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,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,reve,rodin,runway,sora,stability-ai,template,tencent,topaz,tripo,veo,vidu,wan,wavespeed,workflow,quiver-ai}]");
@source inline("icon-[comfy--{ai-model,bfl,bria,bytedance,credits,elevenlabs,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,reve,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--{save-image,load-video,save-video,load-3-d,save-glb,image-batch,batch-images-node,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,lora-loader-model-only,primitive-string-multiline,get-video-components,video-slice,tencent-text-to-model-node,tencent-image-to-model-node,open-ai-chat-node,preview-image,image-and-mask-preview,layer-mask-mask-preview,mask-preview,image-preview-from-latent,i-tools-preview-image,i-tools-compare-image,canny-to-image,image-edit,text-to-image,pose-to-image,depth-to-video,image-to-image,canny-to-video,depth-to-image,image-to-video,pose-to-video,text-to-video,image-inpainting,image-outpainting}]");

View File

@@ -1,3 +0,0 @@
<svg width="281" height="281" viewBox="0 0 281 281" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.069 0C217.427 0.000220786 280.138 62.7116 280.138 140.069V280.138H140.069C62.7116 280.138 0.000220844 217.427 0 140.069C0 62.7114 62.7114 0 140.069 0ZM74.961 66.6054C69.8263 64.8847 64.9385 69.7815 66.6687 74.913L123.558 243.619C125.929 250.65 136.321 248.945 136.321 241.524V135.823H241.329C248.756 135.823 250.453 125.416 243.41 123.056L74.961 66.6054Z" fill="#F8F8F8"/>
</svg>

Before

Width:  |  Height:  |  Size: 534 B

1063
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,10 +13,10 @@ catalog:
'@iconify/utils': ^3.1.0
'@intlify/eslint-plugin-vue-i18n': ^4.1.1
'@lobehub/i18n-cli': ^1.26.1
'@nx/eslint': 22.6.1
'@nx/playwright': 22.6.1
'@nx/storybook': 22.6.1
'@nx/vite': 22.6.1
'@nx/eslint': 22.5.2
'@nx/playwright': 22.5.2
'@nx/storybook': 22.5.2
'@nx/vite': 22.5.2
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
'@primeuix/forms': 0.0.2
@@ -34,9 +34,6 @@ catalog:
'@storybook/vue3': ^10.2.10
'@storybook/vue3-vite': ^10.2.10
'@tailwindcss/vite': ^4.2.0
'@testing-library/jest-dom': ^6.9.1
'@testing-library/user-event': ^14.6.1
'@testing-library/vue': ^8.1.0
'@tiptap/core': ^2.27.2
'@tiptap/extension-link': ^2.27.2
'@tiptap/extension-table': ^2.27.2
@@ -70,7 +67,6 @@ catalog:
eslint-plugin-import-x: ^4.16.1
eslint-plugin-oxlint: 1.55.0
eslint-plugin-storybook: ^10.2.10
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.3.0
eslint-plugin-vue: ^10.6.2
fast-check: ^4.5.3
@@ -83,11 +79,11 @@ catalog:
jsdom: ^27.4.0
jsonata: ^2.1.0
jsondiffpatch: ^0.7.3
knip: ^6.0.1
knip: ^5.75.1
lint-staged: ^16.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.6.1
nx: 22.5.2
oxfmt: ^0.40.0
oxlint: ^1.55.0
oxlint-tsgolint: ^0.17.0

View File

@@ -1,12 +1,15 @@
<template>
<router-view />
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
<TooltipProvider :delay-duration="300" disable-hoverable-content>
<router-view />
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
</TooltipProvider>
</template>
<script setup lang="ts">
import { captureException } from '@sentry/vue'
import BlockUI from 'primevue/blockui'
import { TooltipProvider } from 'reka-ui'
import { computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'

View File

@@ -119,6 +119,7 @@ function createWrapper({
global: {
plugins: [pinia, i18n],
stubs: {
BaseTooltip: { template: '<slot />' },
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
QueueInlineProgressSummary: true,
@@ -131,9 +132,6 @@ function createWrapper({
template: '<div />'
},
...stubs
},
directives: {
tooltip: () => {}
}
}
})

View File

@@ -16,22 +16,23 @@
v-if="managerState.shouldShowManagerButtons.value"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
>
<Button
v-tooltip.bottom="customNodesManagerTooltipConfig"
variant="secondary"
:aria-label="t('menu.manageExtensions')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[comfy--extensions-blocks] size-4" />
<span class="not-md:hidden">
{{ t('menu.manageExtensions') }}
</span>
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
<BaseTooltip :text="t('menu.manageExtensions')" side="bottom">
<Button
variant="secondary"
:aria-label="t('menu.manageExtensions')"
class="relative"
@click="openCustomNodeManager"
>
<i class="icon-[comfy--extensions-blocks] size-4" />
<span class="not-md:hidden">
{{ t('menu.manageExtensions') }}
</span>
<span
v-if="shouldShowRedDot"
class="absolute top-0.5 right-1 size-2 rounded-full bg-red-500"
/>
</Button>
</BaseTooltip>
</div>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
@@ -54,29 +55,37 @@
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
<BaseTooltip
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
:text="t('actionbar.shareTooltip')"
side="bottom"
>
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
<Button
<Button
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
</BaseTooltip>
<BaseTooltip
v-if="!isRightSidePanelOpen"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
:text="t('rightSidePanel.togglePanel')"
side="bottom"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<Button
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</BaseTooltip>
</div>
</div>
<ErrorOverlay />
@@ -133,7 +142,7 @@ import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -243,12 +252,6 @@ const inlineProgressSummaryTarget = computed(() => {
const shouldHideInlineProgressSummary = computed(
() => isQueueProgressOverlayEnabled.value && isQueueOverlayExpanded.value
)
const customNodesManagerTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.manageExtensions'))
)
const shareTooltipConfig = computed(() =>
buildTooltipConfig(t('actionbar.shareTooltip'))
)
const shouldShowRedDot = computed((): boolean => {
return shouldShowConflictRedDot.value
@@ -258,9 +261,6 @@ const { hasAnyError } = storeToRefs(executionErrorStore)
// Right side panel toggle
const { isOpen: isRightSidePanelOpen } = storeToRefs(rightSidePanelStore)
const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()

View File

@@ -36,6 +36,7 @@ const mountActionbar = (showRunProgressBar: boolean) => {
global: {
plugins: [pinia, i18n],
stubs: {
BaseTooltip: { template: '<slot />' },
ContextMenu: {
name: 'ContextMenu',
template: '<div />'
@@ -50,9 +51,6 @@ const mountActionbar = (showRunProgressBar: boolean) => {
template: '<button type="button">Run</button>'
},
QueueInlineProgress: true
},
directives: {
tooltip: () => {}
}
}
})

View File

@@ -32,47 +32,49 @@
<Suspense @resolve="comfyRunButtonResolved">
<ComfyRunButton />
</Suspense>
<Button
v-tooltip.bottom="cancelJobTooltipConfig"
variant="destructive"
size="icon"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
variant="secondary"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? activeSidebarTabId === 'job-history'
: queueOverlayExpanded
"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
<BaseTooltip :text="t('menu.interrupt')" side="bottom">
<Button
variant="destructive"
size="icon"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</BaseTooltip>
<BaseTooltip :text="queueHistoryTooltipText" side="bottom">
<Button
variant="secondary"
size="md"
:aria-pressed="
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
? activeSidebarTabId === 'job-history'
: queueOverlayExpanded
"
class="relative px-3"
data-testid="queue-overlay-toggle"
@click="toggleQueueOverlay"
@contextmenu.stop.prevent="showQueueContextMenu"
>
<span class="text-sm font-normal tabular-nums">
{{ activeJobsLabel }}
</span>
<StatusBadge
v-if="activeJobsCount > 0"
data-testid="active-jobs-indicator"
variant="dot"
class="pointer-events-none absolute -top-0.5 -right-0.5 animate-pulse"
/>
<span class="sr-only">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
</BaseTooltip>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
</div>
</Panel>
@@ -108,7 +110,7 @@ import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
@@ -368,16 +370,11 @@ watch(isDragging, (dragging) => {
}
})
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(
t(
isQueuePanelV2Enabled.value
? 'sideToolbar.queueProgressOverlay.viewJobHistory'
: 'sideToolbar.queueProgressOverlay.expandCollapsedQueue'
)
const queueHistoryTooltipText = computed(() =>
t(
isQueuePanelV2Enabled.value
? 'sideToolbar.queueProgressOverlay.viewJobHistory'
: 'sideToolbar.queueProgressOverlay.expandCollapsedQueue'
)
)
const activeJobsLabel = computed(() => {

View File

@@ -1,4 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
@@ -13,7 +14,6 @@ import {
useQueueSettingsStore,
useQueueStore
} from '@/stores/queueStore'
import { render, screen } from '@/utils/test-utils'
import ComfyQueueButton from './ComfyQueueButton.vue'
@@ -78,40 +78,38 @@ function createTask(id: string, status: JobStatus): TaskItemImpl {
return new TaskItemImpl(job)
}
const stubs = {
BatchCountEdit: BatchCountEditStub,
DropdownMenuRoot: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div><slot /></div>' }
}
function renderQueueButton() {
function createWrapper() {
const pinia = createTestingPinia({ createSpy: vi.fn })
return render(ComfyQueueButton, {
return mount(ComfyQueueButton, {
global: {
plugins: [pinia, i18n],
directives: {
tooltip: () => {}
},
stubs
stubs: {
BatchCountEdit: BatchCountEditStub,
DropdownMenuRoot: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div><slot /></div>' }
}
}
})
}
describe('ComfyQueueButton', () => {
it('renders the batch count control before the run button', () => {
renderQueueButton()
const controls = screen.getAllByTestId(/batch-count-edit|queue-button/)
const wrapper = createWrapper()
const controls = wrapper.get('.queue-button-group').element.children
expect(controls[0]).toHaveAttribute('data-testid', 'batch-count-edit')
expect(controls[1]).toHaveAttribute('data-testid', 'queue-button')
expect(controls[0]?.getAttribute('data-testid')).toBe('batch-count-edit')
expect(controls[1]?.getAttribute('data-testid')).toBe('queue-button')
})
it('keeps the run instant presentation while idle even with active jobs', async () => {
renderQueueButton()
const wrapper = createWrapper()
const queueSettingsStore = useQueueSettingsStore()
const queueStore = useQueueStore()
@@ -119,27 +117,29 @@ describe('ComfyQueueButton', () => {
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
await nextTick()
const queueButton = screen.getByTestId('queue-button')
const queueButton = wrapper.get('[data-testid="queue-button"]')
expect(queueButton).toHaveTextContent('Run (Instant)')
expect(queueButton).toHaveAttribute('data-variant', 'primary')
expect(queueButton.text()).toContain('Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
})
it('switches to stop presentation when instant mode is armed', async () => {
renderQueueButton()
const wrapper = createWrapper()
const queueSettingsStore = useQueueSettingsStore()
queueSettingsStore.mode = 'instant-running'
await nextTick()
const queueButton = screen.getByTestId('queue-button')
const queueButton = wrapper.get('[data-testid="queue-button"]')
expect(queueButton).toHaveTextContent('Stop Run (Instant)')
expect(queueButton).toHaveAttribute('data-variant', 'destructive')
expect(queueButton.text()).toContain('Stop Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('destructive')
expect(wrapper.find('.icon-\\[lucide--square\\]').exists()).toBe(true)
})
it('disarms instant mode without interrupting even when jobs are active', async () => {
const { user } = renderQueueButton()
const wrapper = createWrapper()
const queueSettingsStore = useQueueSettingsStore()
const queueStore = useQueueStore()
const commandStore = useCommandStore()
@@ -148,26 +148,33 @@ describe('ComfyQueueButton', () => {
queueStore.runningTasks = [createTask('run-1', 'in_progress')]
await nextTick()
await user!.click(screen.getByTestId('queue-button'))
await wrapper.get('[data-testid="queue-button"]').trigger('click')
await nextTick()
expect(queueSettingsStore.mode).toBe('instant-idle')
const queueButton = screen.getByTestId('queue-button')
expect(queueButton).toHaveTextContent('Run (Instant)')
expect(queueButton).toHaveAttribute('data-variant', 'primary')
const queueButtonWhileStopping = wrapper.get('[data-testid="queue-button"]')
expect(queueButtonWhileStopping.text()).toContain('Run (Instant)')
expect(queueButtonWhileStopping.attributes('data-variant')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
expect(commandStore.execute).not.toHaveBeenCalled()
const queueButton = wrapper.get('[data-testid="queue-button"]')
expect(queueSettingsStore.mode).toBe('instant-idle')
expect(queueButton.text()).toContain('Run (Instant)')
expect(queueButton.attributes('data-variant')).toBe('primary')
expect(wrapper.find('.icon-\\[lucide--fast-forward\\]').exists()).toBe(true)
})
it('activates instant running mode when queueing again', async () => {
const { user } = renderQueueButton()
const wrapper = createWrapper()
const queueSettingsStore = useQueueSettingsStore()
const commandStore = useCommandStore()
queueSettingsStore.mode = 'instant-idle'
await nextTick()
await user!.click(screen.getByTestId('queue-button'))
await wrapper.get('[data-testid="queue-button"]').trigger('click')
await nextTick()
expect(queueSettingsStore.mode).toBe('instant-running')

View File

@@ -1,55 +0,0 @@
<template>
<NotificationPopup
v-if="appModeStore.showVueNodeSwitchPopup"
:title="$t('appBuilder.vueNodeSwitch.title')"
show-close
position="bottom-left"
@close="dismiss"
>
{{ $t('appBuilder.vueNodeSwitch.content') }}
<template #footer-start>
<label
class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground"
>
<input
v-model="dontShowAgain"
type="checkbox"
class="accent-primary-background"
/>
{{ $t('appBuilder.vueNodeSwitch.dontShowAgain') }}
</label>
</template>
<template #footer-end>
<Button
variant="secondary"
size="lg"
class="font-normal"
@click="dismiss"
>
{{ $t('appBuilder.vueNodeSwitch.dismiss') }}
</Button>
</template>
</NotificationPopup>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import NotificationPopup from '@/components/common/NotificationPopup.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAppModeStore } from '@/stores/appModeStore'
const appModeStore = useAppModeStore()
const settingStore = useSettingStore()
const dontShowAgain = ref(false)
function dismiss() {
if (dontShowAgain.value) {
void settingStore.set('Comfy.AppBuilder.VueNodeSwitchDismissed', true)
}
appModeStore.showVueNodeSwitchPopup = false
}
</script>

View File

@@ -1,78 +0,0 @@
import { mount } from '@vue/test-utils'
import type { ComponentProps } from 'vue-component-type-helpers'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import NotificationPopup from './NotificationPopup.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: { g: { close: 'Close' } }
}
})
function mountPopup(
props: ComponentProps<typeof NotificationPopup> = {
title: 'Test'
},
slots: Record<string, string> = {}
) {
return mount(NotificationPopup, {
global: { plugins: [i18n] },
props,
slots
})
}
describe('NotificationPopup', () => {
it('renders title', () => {
const wrapper = mountPopup({ title: 'Hello World' })
expect(wrapper.text()).toContain('Hello World')
})
it('has role="status" for accessibility', () => {
const wrapper = mountPopup()
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('renders subtitle when provided', () => {
const wrapper = mountPopup({ title: 'T', subtitle: 'v1.2.3' })
expect(wrapper.text()).toContain('v1.2.3')
})
it('renders icon when provided', () => {
const wrapper = mountPopup({
title: 'T',
icon: 'icon-[lucide--rocket]'
})
expect(wrapper.find('i.icon-\\[lucide--rocket\\]').exists()).toBe(true)
})
it('emits close when close button clicked', async () => {
const wrapper = mountPopup({ title: 'T', showClose: true })
await wrapper.find('[aria-label="Close"]').trigger('click')
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('renders default slot content', () => {
const wrapper = mountPopup({ title: 'T' }, { default: 'Body text here' })
expect(wrapper.text()).toContain('Body text here')
})
it('renders footer slots', () => {
const wrapper = mountPopup(
{ title: 'T' },
{ 'footer-start': 'Left side', 'footer-end': 'Right side' }
)
expect(wrapper.text()).toContain('Left side')
expect(wrapper.text()).toContain('Right side')
})
it('positions bottom-right when specified', () => {
const wrapper = mountPopup({ title: 'T', position: 'bottom-right' })
const root = wrapper.find('[role="status"]')
expect(root.attributes('data-position')).toBe('bottom-right')
})
})

View File

@@ -1,87 +0,0 @@
<template>
<div
role="status"
:data-position="position"
:class="
cn(
'pointer-events-auto absolute z-1000 flex max-h-96 w-96 flex-col rounded-lg border border-border-default bg-base-background shadow-interface',
position === 'bottom-left' && 'bottom-4 left-4',
position === 'bottom-right' && 'right-4 bottom-4'
)
"
>
<div class="flex min-h-0 flex-1 flex-col gap-4 p-4">
<div class="flex items-center gap-4">
<div
v-if="icon"
class="flex shrink-0 items-center justify-center rounded-lg bg-primary-background-hover p-3"
>
<i :class="cn('size-4 text-white', icon)" />
</div>
<div class="flex flex-1 flex-col gap-1">
<div class="text-sm leading-[1.429] font-normal text-base-foreground">
{{ title }}
</div>
<div
v-if="subtitle"
class="text-sm leading-[1.21] font-normal text-muted-foreground"
>
{{ subtitle }}
</div>
</div>
<Button
v-if="showClose"
class="size-6 shrink-0 self-start"
size="icon-sm"
variant="muted-textonly"
:aria-label="$t('g.close')"
@click="emit('close')"
>
<i class="icon-[lucide--x] size-3.5" />
</Button>
</div>
<div
v-if="$slots.default"
class="min-h-0 flex-1 overflow-y-auto text-sm text-muted-foreground"
>
<slot />
</div>
</div>
<div
v-if="$slots['footer-start'] || $slots['footer-end']"
class="flex items-center justify-between px-4 pb-4"
>
<div>
<slot name="footer-start" />
</div>
<div class="flex items-center gap-4">
<slot name="footer-end" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const {
icon,
title,
subtitle,
showClose = false,
position = 'bottom-left'
} = defineProps<{
icon?: string
title: string
subtitle?: string
showClose?: boolean
position?: 'bottom-left' | 'bottom-right'
}>()
const emit = defineEmits<{
close: []
}>()
</script>

View File

@@ -97,7 +97,6 @@
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
<VueNodeSwitchPopup />
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
canvasStore.canvas to be initialized. -->
@@ -129,7 +128,6 @@ import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitter
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import VueNodeSwitchPopup from '@/components/builder/VueNodeSwitchPopup.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'

View File

@@ -17,7 +17,11 @@
<!-- Release Notification Toast positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<ReleaseNotificationToast
:position="sidebarLocation === 'right' ? 'bottom-right' : 'bottom-left'"
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
/>
</Teleport>

View File

@@ -52,11 +52,17 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => mockSidebarTabStore
}))
const BaseTooltipStub = {
template: '<slot />'
}
const mountMenu = () =>
mount(JobHistoryActionsMenu, {
global: {
plugins: [i18n],
directives: { tooltip: () => {} }
stubs: {
BaseTooltip: BaseTooltipStub
}
}
})

View File

@@ -2,16 +2,17 @@
<div class="flex items-center gap-1">
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
>
<i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</Button>
<BaseTooltip :text="t('g.more')" side="top">
<Button
variant="textonly"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.moreOptions')"
>
<i
class="icon-[lucide--more-horizontal] block size-4 leading-none text-text-secondary"
/>
</Button>
</BaseTooltip>
</template>
<template #default="{ close }">
<div class="flex min-w-56 flex-col items-stretch font-inter">
@@ -95,7 +96,7 @@ import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -108,7 +109,6 @@ const { t } = useI18n()
const settingStore = useSettingStore()
const sidebarTabStore = useSidebarTabStore()
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
useQueueFeatureFlags()
const showClearHistoryAction = computed(() => !isCloud)

View File

@@ -1,9 +1,8 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import QueueOverlayActive from './QueueOverlayActive.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
const i18n = createI18n({
legacy: false,
@@ -27,11 +26,6 @@ const i18n = createI18n({
}
})
const tooltipDirectiveStub = {
mounted: vi.fn(),
updated: vi.fn()
}
const SELECTORS = {
interruptAllButton: 'button[aria-label="Interrupt all running jobs"]',
clearQueuedButton: 'button[aria-label="Clear queued"]',
@@ -43,6 +37,10 @@ const COPY = {
viewAllJobs: 'View all jobs'
}
const BaseTooltipStub = {
template: '<slot />'
}
const mountComponent = (props: Record<string, unknown> = {}) =>
mount(QueueOverlayActive, {
props: {
@@ -58,8 +56,8 @@ const mountComponent = (props: Record<string, unknown> = {}) =>
},
global: {
plugins: [i18n],
directives: {
tooltip: tooltipDirectiveStub
stubs: {
BaseTooltip: BaseTooltipStub
}
}
})
@@ -113,13 +111,4 @@ describe('QueueOverlayActive', () => {
expect(wrapper.find(SELECTORS.interruptAllButton).exists()).toBe(false)
expect(wrapper.find(SELECTORS.clearQueuedButton).exists()).toBe(false)
})
it('builds tooltip configs with translated strings', () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
mountComponent()
expect(spy).toHaveBeenCalledWith('Cancel job')
expect(spy).toHaveBeenCalledWith('Clear queue')
})
})

View File

@@ -42,18 +42,22 @@
t('sideToolbar.queueProgressOverlay.running')
}}</span>
</span>
<Button
<BaseTooltip
v-if="runningCount > 0"
v-tooltip.top="cancelJobTooltip"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')"
:text="t('sideToolbar.queueProgressOverlay.cancelJobTooltip')"
side="top"
>
<i
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
/>
</Button>
<Button
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')"
>
<i
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
/>
</Button>
</BaseTooltip>
</div>
<div class="flex items-center gap-2">
@@ -63,18 +67,22 @@
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</span>
<Button
<BaseTooltip
v-if="queuedCount > 0"
v-tooltip.top="clearQueueTooltip"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
:text="t('sideToolbar.queueProgressOverlay.clearQueueTooltip')"
side="top"
>
<i
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
/>
</Button>
<Button
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<i
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
/>
</Button>
</BaseTooltip>
</div>
</div>
@@ -91,11 +99,10 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
defineProps<{
totalProgressStyle: Record<string, string>
@@ -115,10 +122,4 @@ defineEmits<{
}>()
const { t } = useI18n()
const cancelJobTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.cancelJobTooltip'))
)
const clearQueueTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearQueueTooltip'))
)
</script>

View File

@@ -48,11 +48,9 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
}))
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
const tooltipDirectiveStub = {
mounted: vi.fn(),
updated: vi.fn()
const BaseTooltipStub = {
template: '<slot />'
}
const mountHeader = (props = {}) =>
@@ -64,7 +62,9 @@ const mountHeader = (props = {}) =>
},
global: {
plugins: [i18n],
directives: { tooltip: tooltipDirectiveStub }
stubs: {
BaseTooltip: BaseTooltipStub
}
}
})
@@ -105,14 +105,11 @@ describe('QueueOverlayHeader', () => {
})
it('emits clear history from the menu', async () => {
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
const wrapper = mountHeader()
expect(wrapper.find('button[aria-label="More options"]').exists()).toBe(
true
)
expect(spy).toHaveBeenCalledWith('More')
const clearHistoryButton = wrapper.get(
'[data-testid="clear-history-action"]'

View File

@@ -11,28 +11,31 @@
<span :class="{ 'opacity-50': queuedCount === 0 }">{{
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
}}</span>
<Button
v-tooltip.top="clearAllJobsTooltip"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
:disabled="queuedCount === 0"
@click="$emit('clearQueued')"
<BaseTooltip
:text="t('sideToolbar.queueProgressOverlay.clearAllJobsTooltip')"
side="top"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
<Button
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
:disabled="queuedCount === 0"
@click="$emit('clearQueued')"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</BaseTooltip>
</div>
<JobHistoryActionsMenu @clear-history="$emit('clearHistory')" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
defineProps<{
headerTitle: string
@@ -45,7 +48,4 @@ defineEmits<{
}>()
const { t } = useI18n()
const clearAllJobsTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearAllJobsTooltip'))
)
</script>

View File

@@ -13,18 +13,22 @@
>
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="filterTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
<BaseTooltip
:text="t('sideToolbar.queueProgressOverlay.filterBy')"
side="top"
>
<i class="icon-[lucide--list-filter] size-4" />
<span
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
>
<i class="icon-[lucide--list-filter] size-4" />
<span
v-if="selectedWorkflowFilter !== 'all'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
</BaseTooltip>
</template>
<template #default="{ close }">
<div class="flex min-w-48 flex-col items-stretch">
@@ -62,18 +66,22 @@
</Popover>
<Popover :show-arrow="false">
<template #button>
<Button
v-tooltip.top="sortTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
<BaseTooltip
:text="t('sideToolbar.queueProgressOverlay.sortBy')"
side="top"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
<span
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
>
<i class="icon-[lucide--arrow-up-down] size-4" />
<span
v-if="selectedSortMode !== 'mostRecent'"
class="pointer-events-none absolute -top-1 -right-1 inline-block size-2 rounded-full bg-base-foreground"
/>
</Button>
</BaseTooltip>
</template>
<template #default="{ close }">
<div class="flex min-w-48 flex-col items-stretch">
@@ -98,16 +106,20 @@
</div>
</template>
</Popover>
<Button
<BaseTooltip
v-if="showAssetsAction"
v-tooltip.top="showAssetsTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
@click="emit('showAssets')"
:text="t('sideToolbar.queueProgressOverlay.showAssets')"
side="top"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
@click="emit('showAssets')"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
</BaseTooltip>
</div>
</div>
</template>
@@ -121,7 +133,7 @@ import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { jobSortModes } from '@/composables/queue/useJobList'
import type { JobSortMode } from '@/composables/queue/useJobList'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
const {
hideShowAssetsAction = false,
@@ -148,15 +160,6 @@ const emit = defineEmits<{
const { t } = useI18n()
const filterTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.filterBy'))
)
const sortTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.sortBy'))
)
const showAssetsTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.showAssets'))
)
const showAssetsAction = computed(() => !hideShowAssetsAction)
const searchPlaceholderText = computed(
() => searchPlaceholder ?? t('sideToolbar.queueProgressOverlay.searchJobs')

View File

@@ -54,6 +54,10 @@ const i18n = createI18n({
}
})
const BaseTooltipStub = {
template: '<slot />'
}
describe('JobFiltersBar', () => {
it('emits showAssets when the assets icon button is clicked', async () => {
const wrapper = mount(JobFiltersBar, {
@@ -65,7 +69,9 @@ describe('JobFiltersBar', () => {
},
global: {
plugins: [i18n],
directives: { tooltip: () => undefined }
stubs: {
BaseTooltip: BaseTooltipStub
}
}
})
@@ -88,7 +94,9 @@ describe('JobFiltersBar', () => {
},
global: {
plugins: [i18n],
directives: { tooltip: () => undefined }
stubs: {
BaseTooltip: BaseTooltipStub
}
}
})

View File

@@ -124,30 +124,38 @@
key="actions"
class="inline-flex items-center gap-2 pr-1"
>
<Button
<BaseTooltip
v-if="state === 'failed' && computedShowClear"
v-tooltip.top="deleteTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="onDeleteClick"
:text="t('g.delete')"
side="top"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
<Button
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="onDeleteClick"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
</BaseTooltip>
<BaseTooltip
v-else-if="
state !== 'completed' &&
state !== 'running' &&
computedShowClear
"
v-tooltip.top="cancelTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
:text="t('g.cancel')"
side="top"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</BaseTooltip>
<Button
v-else-if="state === 'completed'"
variant="textonly"
@@ -155,32 +163,40 @@
@click.stop="emit('view')"
>{{ t('menuLabels.View') }}</Button
>
<Button
<BaseTooltip
v-if="showMenu !== undefined ? showMenu : true"
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon-sm"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
:text="t('g.more')"
side="top"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
>
<i class="icon-[lucide--more-horizontal] size-4" />
</Button>
</BaseTooltip>
</div>
<div v-else-if="state !== 'running'" key="secondary" class="pr-2">
<slot name="secondary">{{ rightText }}</slot>
</div>
</Transition>
<!-- Running job cancel button - always visible -->
<Button
<BaseTooltip
v-if="state === 'running' && computedShowClear"
v-tooltip.top="cancelTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
:text="t('g.cancel')"
side="top"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</BaseTooltip>
</div>
</div>
</div>
@@ -195,7 +211,7 @@ import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverP
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
@@ -247,10 +263,6 @@ const {
progressPercentStyle
} = useProgressBarBackground()
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const rowRef = ref<HTMLDivElement | null>(null)
const showDetails = computed(() => activeDetailsId === jobId)

View File

@@ -19,13 +19,10 @@ export const buttonVariants = cva({
'bg-transparent text-muted-foreground hover:bg-secondary-background-hover',
'destructive-textonly':
'bg-transparent text-destructive-background hover:bg-destructive-background/10',
link: 'bg-transparent text-muted-foreground hover:text-base-foreground',
'overlay-white': 'bg-white text-gray-600 hover:bg-white/90',
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90',
outline:
'border border-solid border-border-subtle bg-transparent text-base-foreground hover:bg-secondary-background-hover'
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -54,11 +51,9 @@ const variants = [
'textonly',
'muted-textonly',
'destructive-textonly',
'link',
'base',
'overlay-white',
'gradient',
'outline'
'gradient'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = [
'sm',

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import {
TooltipArrow,
TooltipContent,
TooltipPortal,
TooltipRoot,
TooltipTrigger
} from 'reka-ui'
import type { TooltipVariants } from '@/components/ui/tooltip/tooltip.variants'
import { tooltipVariants } from '@/components/ui/tooltip/tooltip.variants'
import { cn } from '@/utils/tailwindUtil'
const {
text = '',
side = 'top',
sideOffset = 4,
size = 'small',
delayDuration,
disabled = false,
class: className
} = defineProps<{
text?: string
side?: 'top' | 'bottom' | 'left' | 'right'
sideOffset?: number
size?: NonNullable<TooltipVariants['size']>
delayDuration?: number
disabled?: boolean
class?: HTMLAttributes['class']
}>()
</script>
<template>
<TooltipRoot :delay-duration="delayDuration" :disabled="disabled || !text">
<TooltipTrigger as-child>
<slot />
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
:side="side"
:side-offset="sideOffset"
:class="cn(tooltipVariants({ size }), className)"
>
{{ text }}
<TooltipArrow
:width="8"
:height="5"
class="fill-node-component-tooltip-surface"
/>
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</template>

View File

@@ -0,0 +1,17 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const tooltipVariants = cva({
base: 'z-50 select-none border border-node-component-tooltip-border bg-node-component-tooltip-surface px-4 py-2 text-node-component-tooltip shadow-none',
variants: {
size: {
small: 'rounded-lg text-xs',
large: 'max-w-75 rounded-sm text-sm/tight font-normal'
}
},
defaultVariants: {
size: 'small'
}
})
export type TooltipVariants = VariantProps<typeof tooltipVariants>

View File

@@ -1,18 +0,0 @@
/**
* Build a tooltip configuration object compatible with v-tooltip.
* Consumers pass the translated text value.
*/
export const buildTooltipConfig = (value: string) => ({
value,
showDelay: 300,
hideDelay: 0,
pt: {
text: {
class:
'border-node-component-tooltip-border bg-node-component-tooltip-surface text-node-component-tooltip border rounded-md px-2 py-1 text-xs leading-none shadow-none'
},
arrow: {
class: 'border-t-node-component-tooltip-border'
}
}
})

View File

@@ -2,7 +2,7 @@ import { app } from '../../scripts/app'
import { ComfyApp } from '../../scripts/app'
import { $el, ComfyDialog } from '../../scripts/ui'
class ClipspaceDialog extends ComfyDialog {
export class ClipspaceDialog extends ComfyDialog {
static items: Array<
HTMLButtonElement & {
contextPredicate?: () => boolean

View File

@@ -3232,14 +3232,6 @@
"desc": " More flexible workflows, powerful new widgets, built for extensibility",
"tryItOut": "Try it out"
},
"appBuilder": {
"vueNodeSwitch": {
"title": "Switched over to Nodes 2.0",
"content": "For the best experience, App builder uses Nodes 2.0. You can switch back after building the app from the main menu.",
"dontShowAgain": "Don't show again",
"dismiss": "Dismiss"
}
},
"vueNodesMigration": {
"message": "Prefer the legacy design?",
"button": "Switch back"

View File

@@ -1198,12 +1198,6 @@ export const CORE_SETTINGS: SettingParams[] = [
experimental: true,
versionAdded: '1.27.1'
},
{
id: 'Comfy.AppBuilder.VueNodeSwitchDismissed',
name: 'App Builder Vue Node switch dismissed',
type: 'hidden',
defaultValue: false
},
{
id: 'Comfy.VueNodes.AutoScaleLayout',
category: ['Comfy', 'Nodes 2.0', 'AutoScaleLayout'],

View File

@@ -1,5 +1,3 @@
import { useSettingStore } from '@/platform/settings/settingStore'
import type { FeatureSurveyConfig } from './useSurveyEligibility'
/**
@@ -11,13 +9,7 @@ export const FEATURE_SURVEYS: Record<string, FeatureSurveyConfig> = {
featureId: 'node-search',
typeformId: 'goZLqjKL',
triggerThreshold: 3,
delayMs: 5000,
isFeatureActive: () => {
const settingStore = useSettingStore()
return (
settingStore.get('Comfy.NodeSearchBoxImpl') !== 'litegraph (legacy)'
)
}
delayMs: 5000
}
}

View File

@@ -181,17 +181,6 @@ describe('useSurveyEligibility', () => {
expect(isEligible.value).toBe(false)
})
it('is not eligible when isFeatureActive returns false', () => {
setFeatureUsage('test-feature', 5)
const { isEligible } = useSurveyEligibility({
...defaultConfig,
isFeatureActive: () => false
})
expect(isEligible.value).toBe(false)
})
})
describe('actions', () => {

View File

@@ -13,7 +13,6 @@ export interface FeatureSurveyConfig {
triggerThreshold?: number
delayMs?: number
enabled?: boolean
isFeatureActive?: () => boolean
}
interface SurveyState {
@@ -62,13 +61,8 @@ export function useSurveyEligibility(
const hasOptedOut = computed(() => state.value.optedOut)
const isFeatureActive = computed(
() => resolvedConfig.value.isFeatureActive?.() ?? true
)
const isEligible = computed(() => {
if (!isSurveyEnabled.value) return false
if (!isFeatureActive.value) return false
if (!isNightlyLocalhost.value) return false
if (!hasReachedThreshold.value) return false
if (hasSeenSurvey.value) return false

View File

@@ -134,7 +134,7 @@ describe('ReleaseNotificationToast', () => {
} as ReleaseNote
wrapper = mountComponent()
expect(wrapper.find('.release-toast-popup').exists()).toBe(true)
expect(wrapper.find('.icon-\\[lucide--rocket\\]').exists()).toBe(true)
})
it('displays release version', () => {

View File

@@ -1,17 +1,40 @@
<template>
<div v-if="shouldShow" class="release-toast-popup">
<NotificationPopup
icon="icon-[lucide--rocket]"
:title="$t('releaseToast.newVersionAvailable')"
:subtitle="latestRelease?.version"
:position
<div
class="flex max-h-96 w-96 flex-col rounded-lg border border-border-default bg-base-background shadow-[1px_1px_8px_0_rgba(0,0,0,0.4)]"
>
<div
class="pl-14 text-sm leading-[1.21] font-normal text-muted-foreground"
v-html="formattedContent"
></div>
<!-- Main content -->
<div class="flex min-h-0 flex-1 flex-col gap-4 p-4">
<!-- Header section with icon and text -->
<div class="flex items-center gap-4">
<div
class="flex shrink-0 items-center justify-center rounded-lg bg-primary-background-hover p-3"
>
<i class="icon-[lucide--rocket] size-4 text-white" />
</div>
<div class="flex flex-col gap-1">
<div
class="text-sm leading-[1.429] font-normal text-base-foreground"
>
{{ $t('releaseToast.newVersionAvailable') }}
</div>
<div
class="text-sm leading-[1.21] font-normal text-muted-foreground"
>
{{ latestRelease?.version }}
</div>
</div>
</div>
<template #footer-start>
<!-- Description section -->
<div
class="min-h-0 flex-1 overflow-y-auto pl-14 text-sm leading-[1.21] font-normal text-muted-foreground"
v-html="formattedContent"
></div>
</div>
<!-- Footer section -->
<div class="flex items-center justify-between px-4 pb-4">
<a
class="flex items-center gap-2 py-1 text-sm font-normal text-muted-foreground hover:text-base-foreground"
:href="changelogUrl"
@@ -22,27 +45,22 @@
<i class="icon-[lucide--external-link] size-4"></i>
{{ $t('releaseToast.whatsNew') }}
</a>
</template>
<template #footer-end>
<Button
variant="link"
size="unset"
class="h-6 px-0 text-sm font-normal"
@click="handleSkip"
>
{{ $t('releaseToast.skip') }}
</Button>
<Button
variant="secondary"
size="lg"
class="font-normal"
@click="handleUpdate"
>
{{ $t('releaseToast.update') }}
</Button>
</template>
</NotificationPopup>
<div class="flex items-center gap-4">
<button
class="h-6 cursor-pointer border-none bg-transparent px-0 text-sm font-normal text-muted-foreground hover:text-base-foreground"
@click="handleSkip"
>
{{ $t('releaseToast.skip') }}
</button>
<button
class="h-10 cursor-pointer rounded-lg border-none bg-secondary-background px-4 text-sm font-normal text-base-foreground hover:bg-secondary-background-hover"
@click="handleUpdate"
>
{{ $t('releaseToast.update') }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -51,8 +69,6 @@ import { default as DOMPurify } from 'dompurify'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import NotificationPopup from '@/components/common/NotificationPopup.vue'
import Button from '@/components/ui/button/Button.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useExternalLink } from '@/composables/useExternalLink'
import { useCommandStore } from '@/stores/commandStore'
@@ -63,10 +79,6 @@ import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import type { ReleaseNote } from '../common/releaseService'
import { useReleaseStore } from '../common/releaseStore'
const { position = 'bottom-left' } = defineProps<{
position?: 'bottom-left' | 'bottom-right'
}>()
const { buildDocsUrl } = useExternalLink()
const { toastErrorHandler } = useErrorHandling()
const releaseStore = useReleaseStore()
@@ -206,3 +218,23 @@ defineExpose({
handleUpdate
})
</script>
<style scoped>
/* Toast popup - positioning handled by parent */
.release-toast-popup {
position: absolute;
bottom: 1rem;
z-index: 1000;
pointer-events: auto;
}
/* Sidebar positioning classes applied by parent - matching help center */
.release-toast-popup.sidebar-left,
.release-toast-popup.sidebar-left.small-sidebar {
left: 1rem;
}
.release-toast-popup.sidebar-right {
right: 1rem;
}
</style>

View File

@@ -46,12 +46,11 @@
onThumbnailError($event.name, $event.previewUrl)
"
/>
<span
v-tooltip="buildTooltipConfig(item.name)"
class="truncate text-xs text-base-foreground"
>
{{ item.name }}
</span>
<BaseTooltip :text="item.name">
<span class="truncate text-xs text-base-foreground">
{{ item.name }}
</span>
</BaseTooltip>
<span
v-if="item.in_library"
class="ml-auto shrink-0 text-xs text-muted-foreground"
@@ -77,7 +76,7 @@ import ShareAssetThumbnail from '@/platform/workflow/sharing/components/ShareAss
import { useAssetSections } from '@/platform/workflow/sharing/composables/useAssetSections'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
const { items } = defineProps<{
items: AssetInfo[]

View File

@@ -53,7 +53,10 @@ describe(ShareAssetWarningBox, () => {
...props
},
global: {
plugins: [i18n]
plugins: [i18n],
stubs: {
BaseTooltip: { template: '<slot />' }
}
}
})
}

View File

@@ -187,6 +187,7 @@ describe('ShareWorkflowDialogContent', () => {
global: {
plugins: [i18n],
stubs: {
BaseTooltip: { template: '<slot />' },
ComfyHubPublishIntroPanel: {
template:
'<section data-testid="publish-intro"><button data-testid="publish-intro-cta" @click="$props.onCreateProfile()">Start publishing</button></section>',

View File

@@ -9,7 +9,6 @@ defineOptions({ inheritAttrs: false })
const { src } = defineProps<{
src: string
mobile?: boolean
label?: string
}>()
const imageRef = useTemplateRef('imageRef')
@@ -49,8 +48,5 @@ const height = ref('')
}
"
/>
<span class="self-center md:z-10">
{{ `${width} x ${height}` }}
<template v-if="label"> | {{ label }}</template>
</span>
<span class="self-center md:z-10" v-text="`${width} x ${height}`" />
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, useAttrs } from 'vue'
import { defineAsyncComponent, useAttrs } from 'vue'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
@@ -19,56 +19,40 @@ const { output } = defineProps<{
}>()
const attrs = useAttrs()
const mediaType = computed(() => getMediaType(output))
const outputLabel = computed(
() => output.display_name?.trim() || output.filename
)
</script>
<template>
<template v-if="mediaType === 'images' || mediaType === 'video'">
<ImagePreview
v-if="mediaType === 'images'"
:class="attrs.class as string"
:mobile
:src="output.url"
:label="outputLabel"
/>
<VideoPreview
v-else
:src="output.url"
:label="outputLabel"
:class="
cn(
'flex-1 object-contain md:p-3 md:contain-size',
attrs.class as string
)
"
/>
</template>
<template v-else>
<audio
v-if="mediaType === 'audio'"
:class="cn('m-auto w-full', attrs.class as string)"
controls
:src="output.url"
/>
<article
v-else-if="mediaType === 'text'"
:class="
cn(
'm-auto my-12 size-full max-w-2xl scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap',
attrs.class as string
)
"
v-text="output.content"
/>
<Preview3d
v-else-if="mediaType === '3d'"
:class="attrs.class as string"
:model-url="output.url"
/>
<span v-if="outputLabel" class="self-center text-sm">
{{ outputLabel }}
</span>
</template>
<ImagePreview
v-if="getMediaType(output) === 'images'"
:class="attrs.class as string"
:mobile
:src="output.url"
/>
<VideoPreview
v-else-if="getMediaType(output) === 'video'"
:src="output.url"
:class="
cn('flex-1 object-contain md:p-3 md:contain-size', attrs.class as string)
"
/>
<audio
v-else-if="getMediaType(output) === 'audio'"
:class="cn('m-auto w-full', attrs.class as string)"
controls
:src="output.url"
/>
<article
v-else-if="getMediaType(output) === 'text'"
:class="
cn(
'm-auto my-12 size-full max-w-2xl scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap',
attrs.class as string
)
"
v-text="output.content"
/>
<Preview3d
v-else-if="getMediaType(output) === '3d'"
:class="attrs.class as string"
:model-url="output.url"
/>
</template>

View File

@@ -3,7 +3,6 @@ import { ref, useTemplateRef } from 'vue'
const { src } = defineProps<{
src: string
label?: string
}>()
const videoRef = useTemplateRef('videoRef')
@@ -24,8 +23,5 @@ const height = ref('')
}
"
/>
<span class="z-10 self-center">
{{ `${width} x ${height}` }}
<template v-if="label"> | {{ label }}</template>
</span>
<span class="z-10 self-center" v-text="`${width} x ${height}`" />
</template>

View File

@@ -1,63 +1,71 @@
<template>
<div v-if="renderError" class="node-error p-1 text-xs text-red-500"></div>
<div
<BaseTooltip
v-else
v-tooltip.left="tooltipConfig"
:class="
cn(
'lg-slot lg-slot--input group m-0 flex items-center rounded-r-lg',
'cursor-crosshair',
dotOnly ? 'lg-slot--dot-only' : 'pr-6',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible,
'opacity-40': shouldDim
},
props.socketless && 'pointer-events-none invisible'
)
"
:text="inputTooltipText"
side="left"
size="large"
:delay-duration="tooltipDelay"
:disabled="!tooltipsEnabled"
>
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
<div
:class="
cn(
'w-3 -translate-x-1/2',
hasError &&
'before:pointer-events-none before:absolute before:size-4 before:rounded-full before:ring-2 before:ring-error before:ring-offset-0'
'lg-slot lg-slot--input group m-0 flex items-center rounded-r-lg',
'cursor-crosshair',
dotOnly ? 'lg-slot--dot-only' : 'pr-6',
{
'lg-slot--connected': props.connected,
'lg-slot--compatible': props.compatible,
'opacity-40': shouldDim
},
props.socketless && 'pointer-events-none invisible'
)
"
:slot-data
@click="onClick"
@dblclick="onDoubleClick"
@pointerdown="onPointerDown"
/>
<!-- Slot Name -->
<div class="flex h-full min-w-0 items-center">
<span
v-if="!props.dotOnly && !hasNoLabel"
>
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
:class="
cn(
'truncate text-node-component-slot-text',
hasError && 'font-medium text-error'
'w-3 -translate-x-1/2',
hasError &&
'before:pointer-events-none before:absolute before:size-4 before:rounded-full before:ring-2 before:ring-error before:ring-offset-0'
)
"
>
{{
slotData.label ||
slotData.localized_name ||
(slotData.name ?? `Input ${index}`)
}}
</span>
:slot-data
@click="onClick"
@dblclick="onDoubleClick"
@pointerdown="onPointerDown"
/>
<!-- Slot Name -->
<div class="flex h-full min-w-0 items-center">
<span
v-if="!props.dotOnly && !hasNoLabel"
:class="
cn(
'truncate text-node-component-slot-text',
hasError && 'font-medium text-error'
)
"
>
{{
slotData.label ||
slotData.localized_name ||
(slotData.name ?? `Input ${index}`)
}}
</span>
</div>
</div>
</div>
</BaseTooltip>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref, watchEffect } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useSlotLinkDragUIState } from '@/renderer/core/canvas/links/slotLinkDragUIState'
@@ -94,15 +102,13 @@ const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
const { getInputSlotTooltip, createTooltipConfig } = useNodeTooltips(
const { getInputSlotTooltip, tooltipsEnabled, tooltipDelay } = useNodeTooltips(
props.nodeType || ''
)
const tooltipConfig = computed(() => {
const inputTooltipText = computed(() => {
const slotName = props.slotData.localized_name || props.slotData.name || ''
const tooltipText = getInputSlotTooltip(slotName)
const fallbackText = tooltipText || `Input: ${slotName}`
return createTooltipConfig(fallbackText)
return getInputSlotTooltip(slotName) || `Input: ${slotName}`
})
onErrorCaptured((error) => {

View File

@@ -192,6 +192,7 @@ describe('LGraphNode', () => {
global: {
plugins: [pinia, i18n],
stubs: {
BaseTooltip: { template: '<div><slot /></div>' },
NodeSlots: true,
NodeWidgets: true,
NodeContent: true,

View File

@@ -96,11 +96,9 @@ const createMountConfig = () => {
global: {
plugins: [PrimeVue, i18n, pinia],
components: { InputText },
directives: {
tooltip: {
mounted: vi.fn(),
updated: vi.fn(),
unmounted: vi.fn()
stubs: {
BaseTooltip: {
template: '<div><slot /></div>'
}
}
}
@@ -190,56 +188,13 @@ describe('NodeHeader.vue', () => {
})
describe('Tooltips', () => {
it('applies tooltip directive to node title with correct configuration', () => {
it('renders node title inside a tooltip wrapper', () => {
const wrapper = mountHeader({
nodeData: makeNodeData({ type: 'KSampler' })
})
const titleElement = wrapper.find('[data-testid="node-title"]')
expect(titleElement.exists()).toBe(true)
// Check that v-tooltip directive was applied
const directive = wrapper.vm.$el.querySelector(
'[data-testid="node-title"]'
)
expect(directive).toBeTruthy()
})
it('disables tooltip when editing is active', async () => {
const wrapper = mountHeader({
nodeData: makeNodeData({ type: 'KSampler' })
})
// Enter edit mode
await wrapper.get('[data-testid="node-header-1"]').trigger('dblclick')
// Tooltip should be disabled during editing
const titleElement = wrapper.find('[data-testid="node-title"]')
expect(titleElement.exists()).toBe(true)
})
it('creates tooltip configuration when component mounts', () => {
const wrapper = mountHeader({
nodeData: makeNodeData({ type: 'KSampler' })
})
// Verify tooltip directive is applied to the title element
const titleElement = wrapper.find('[data-testid="node-title"]')
expect(titleElement.exists()).toBe(true)
// The tooltip composable should be initialized
expect(wrapper.vm).toBeDefined()
})
it('uses tooltip container from provide/inject', () => {
const wrapper = mountHeader({
nodeData: makeNodeData({ type: 'KSampler' })
})
expect(wrapper.exists()).toBe(true)
// Container should be provided through inject
const titleElement = wrapper.find('[data-testid="node-title"]')
expect(titleElement.exists()).toBe(true)
})
})
})

View File

@@ -38,21 +38,28 @@
</Button>
</div>
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"
class="flex min-w-0 flex-1 items-center gap-2"
data-testid="node-title"
<BaseTooltip
:text="isEditing ? '' : getNodeDescription"
side="top"
size="large"
:delay-duration="tooltipDelay"
:disabled="!tooltipsEnabled"
>
<div class="flex-1 truncate">
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
/>
<div
class="flex min-w-0 flex-1 items-center gap-2"
data-testid="node-title"
>
<div class="flex-1 truncate">
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
/>
</div>
</div>
</div>
</BaseTooltip>
</div>
<template v-for="badge in priceBadges ?? []" :key="badge.required">
@@ -89,6 +96,7 @@ import { computed, onErrorCaptured, ref, watch } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
@@ -126,18 +134,10 @@ onErrorCaptured((error) => {
// Editing state
const isEditing = ref(false)
const { getNodeDescription, createTooltipConfig } = useNodeTooltips(
const { getNodeDescription, tooltipsEnabled, tooltipDelay } = useNodeTooltips(
nodeData?.type || ''
)
const tooltipConfig = computed(() => {
if (isEditing.value) {
return { value: '', disabled: true }
}
const description = getNodeDescription.value
return createTooltipConfig(description)
})
const resolveTitle = (info: VueNodeData | undefined) => {
const untitledLabel = st('g.untitled', 'Untitled')
return resolveNodeDisplayName(info ?? null, {

View File

@@ -55,22 +55,29 @@
:name="widget.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
<BaseTooltip
:text="widget.tooltipText"
side="left"
size="large"
:delay-duration="tooltipDelay"
:disabled="!tooltipsEnabled"
>
<component
:is="widget.vueComponent"
v-model="widget.value"
:widget="widget.simplified"
:node-id="nodeData?.id != null ? String(nodeData.id) : ''"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</BaseTooltip>
</AppInput>
</div>
</template>
@@ -78,10 +85,10 @@
</template>
<script setup lang="ts">
import type { TooltipOptions } from 'primevue'
import { computed, onErrorCaptured, ref, toValue } from 'vue'
import type { Component } from 'vue'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import type {
SafeWidgetData,
VueNodeData,
@@ -182,7 +189,7 @@ const showAdvanced = computed(
nodeData?.showAdvanced ||
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
const { getWidgetTooltip, tooltipsEnabled, tooltipDelay } = useNodeTooltips(
nodeType.value
)
const widgetValueStore = useWidgetValueStore()
@@ -217,7 +224,7 @@ interface ProcessedWidget {
name: string
renderKey: string
simplified: SimplifiedWidget
tooltipConfig: TooltipOptions
tooltipText: string
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
@@ -426,7 +433,6 @@ const processedWidgets = computed((): ProcessedWidget[] => {
)
const tooltipText = getWidgetTooltip(widget)
const tooltipConfig = createTooltipConfig(tooltipText)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
@@ -454,7 +460,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
simplified,
value,
updateHandler,
tooltipConfig,
tooltipText,
slotMetadata
})
}

View File

@@ -1,27 +1,36 @@
<template>
<div v-if="renderError" class="node-error p-1 text-xs text-red-500"></div>
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
<div class="relative flex h-full min-w-0 items-center">
<!-- Slot Name -->
<span
v-if="!props.dotOnly && !hasNoLabel"
class="truncate text-node-component-slot-text"
>
{{
slotData.label ||
slotData.localized_name ||
(slotData.name ?? `Output ${index}`)
}}
</span>
<BaseTooltip
v-else
:text="outputTooltipText"
side="right"
size="large"
:delay-duration="tooltipDelay"
:disabled="!tooltipsEnabled"
>
<div :class="slotWrapperClass">
<div class="relative flex h-full min-w-0 items-center">
<!-- Slot Name -->
<span
v-if="!props.dotOnly && !hasNoLabel"
class="truncate text-node-component-slot-text"
>
{{
slotData.label ||
slotData.localized_name ||
(slotData.name ?? `Output ${index}`)
}}
</span>
</div>
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
class="w-3 translate-x-1/2"
:slot-data
@pointerdown="onPointerDown"
/>
</div>
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
class="w-3 translate-x-1/2"
:slot-data
@pointerdown="onPointerDown"
/>
</div>
</BaseTooltip>
</template>
<script setup lang="ts">
@@ -29,6 +38,7 @@ import { computed, onErrorCaptured, ref, watchEffect } from 'vue'
import type { ComponentPublicInstance } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { RenderShape } from '@/lib/litegraph/src/types/globalEnums'
@@ -65,11 +75,11 @@ const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
const { getOutputSlotTooltip, createTooltipConfig } = useNodeTooltips(
const { getOutputSlotTooltip, tooltipsEnabled, tooltipDelay } = useNodeTooltips(
props.nodeType || ''
)
const tooltipConfig = computed(() => {
const outputTooltipText = computed(() => {
const slotName = props.slotData.name || ''
const tooltipText = getOutputSlotTooltip(props.index)
const fallbackText = tooltipText || `Output: ${slotName}`
@@ -77,7 +87,7 @@ const tooltipConfig = computed(() => {
props.slotData.shape === RenderShape.GRID
? ` ${t('vueNodesSlot.iterative')}`
: ''
return createTooltipConfig(fallbackText + iterativeSuffix)
return fallbackText + iterativeSuffix
})
onErrorCaptured((error) => {

View File

@@ -1,8 +1,4 @@
import type {
TooltipOptions,
TooltipPassThroughMethodOptions
} from 'primevue/tooltip'
import { computed, ref, unref } from 'vue'
import { computed, unref } from 'vue'
import type { MaybeRef } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
@@ -10,77 +6,6 @@ import { st } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
// PrimeVue adds this internal property to elements with tooltips
interface PrimeVueTooltipElement extends Element {
$_ptooltipId?: string
}
/**
* Hide all visible tooltips by dispatching mouseleave events
*
*
* IMPORTANT: this escape is needed for many reason due to primevue's directive tooltip system.
* We cannot use PT to conditionally render the tooltips because the entire PT object only run
* once during the initialization of the directive not every mount/unmount.
* Once the directive is constructed its no longer reactive in the traditional sense.
* We have to use something non destructive like mouseevents to dismiss the tooltip.
*
* TODO: use a better tooltip component like RekaUI for vue nodes specifically.
*/
const tooltipsTemporarilyDisabled = ref(false)
const hideTooltipsGlobally = () => {
// Get all visible tooltip elements
const tooltips = document.querySelectorAll('.p-tooltip')
// Early return if no tooltips are visible
if (tooltips.length === 0) return
tooltips.forEach((tooltipEl) => {
const tooltipId = tooltipEl.id
if (!tooltipId) return
// Find the target element that owns this tooltip
const targetElements = document.querySelectorAll('[data-pd-tooltip="true"]')
for (const targetEl of targetElements) {
if ((targetEl as PrimeVueTooltipElement).$_ptooltipId === tooltipId) {
;(targetEl as HTMLElement).dispatchEvent(
new MouseEvent('mouseleave', { bubbles: true })
)
break
}
}
})
// Disable tooltips temporarily after hiding (for drag operations)
tooltipsTemporarilyDisabled.value = true
}
/**
* Re-enable tooltips after pointer interaction ends
*/
const handlePointerUp = () => {
tooltipsTemporarilyDisabled.value = false
}
// Global tooltip hiding system
const globalTooltipState = { listenersSetup: false }
function setupGlobalTooltipHiding() {
if (globalTooltipState.listenersSetup) return
document.addEventListener('pointerdown', hideTooltipsGlobally)
document.addEventListener('pointerup', handlePointerUp)
window.addEventListener('wheel', hideTooltipsGlobally, {
capture: true, //Need this to bypass the event layer from Litegraph
passive: true
})
globalTooltipState.listenersSetup = true
}
/**
* Composable for managing Vue node tooltips
@@ -90,15 +15,14 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
const nodeDefStore = useNodeDefStore()
const settingsStore = useSettingStore()
// Setup global pointerdown listener once
setupGlobalTooltipHiding()
// Check if tooltips are globally enabled
const tooltipsEnabled = computed(() =>
settingsStore.get('Comfy.EnableTooltips')
)
// Get node definition for tooltip data
const tooltipDelay = computed(
() => settingsStore.get('LiteGraph.Node.TooltipDelay') as number
)
const nodeDef = computed(() => nodeDefStore.nodeDefsByName[unref(nodeType)])
/**
@@ -114,7 +38,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for input slots
*/
const getInputSlotTooltip = (slotName: string) => {
function getInputSlotTooltip(slotName: string) {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(slotName)}.tooltip`
@@ -125,7 +49,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for output slots
*/
const getOutputSlotTooltip = (slotIndex: number) => {
function getOutputSlotTooltip(slotIndex: number) {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.outputs.${slotIndex}.tooltip`
@@ -136,7 +60,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for widgets
*/
const getWidgetTooltip = (widget: SafeWidgetData) => {
function getWidgetTooltip(widget: SafeWidgetData) {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
// First try widget-specific tooltip
@@ -149,46 +73,12 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
return st(key, inputTooltip)
}
/**
* Create tooltip configuration object for v-tooltip directive
* Components wrap this in computed() for reactivity
*/
const createTooltipConfig = (text: string): TooltipOptions => {
const tooltipDelay = settingsStore.get('LiteGraph.Node.TooltipDelay')
const tooltipText = text || ''
return {
value: tooltipText,
showDelay: tooltipDelay as number,
hideDelay: 0, // Immediate hiding
disabled:
!tooltipsEnabled.value ||
!tooltipText ||
tooltipsTemporarilyDisabled.value, // this reactive value works but only on next mount,
// so if the tooltip is already visible changing this will not hide it
pt: {
text: {
class:
'border-node-component-tooltip-border bg-node-component-tooltip-surface border rounded-md px-4 py-2 text-node-component-tooltip text-sm font-normal leading-tight max-w-75 shadow-none'
},
arrow: ({ context }: TooltipPassThroughMethodOptions) => ({
class: cn(
context.top && 'border-t-node-component-tooltip-border',
context.bottom && 'border-b-node-component-tooltip-border',
context.left && 'border-l-node-component-tooltip-border',
context.right && 'border-r-node-component-tooltip-border'
)
})
}
}
}
return {
tooltipsEnabled,
tooltipDelay,
getNodeDescription,
getInputSlotTooltip,
getOutputSlotTooltip,
getWidgetTooltip,
createTooltipConfig
getWidgetTooltip
}
}

View File

@@ -55,33 +55,6 @@ vi.mock(
})
)
const { mockMediaAssets, mockResolveOutputAssetItems } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
return {
mockMediaAssets: {
media: ref([]),
loading: ref(false),
error: ref(null),
fetchMediaList: vi.fn().mockResolvedValue([]),
refresh: vi.fn().mockResolvedValue([]),
loadMore: vi.fn(),
hasMore: ref(false),
isLoadingMore: ref(false)
},
mockResolveOutputAssetItems: vi.fn()
}
})
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
useMediaAssets: () => mockMediaAssets
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', () => ({
resolveOutputAssetItems: (...args: unknown[]) =>
mockResolveOutputAssetItems(...args)
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -511,229 +484,6 @@ describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
})
})
describe('WidgetSelectDropdown multi-output jobs', () => {
interface MultiOutputInstance extends ComponentPublicInstance {
outputItems: FormDropdownItem[]
}
function makeMultiOutputAsset(
jobId: string,
name: string,
nodeId: string,
outputCount: number
) {
return {
id: jobId,
name,
preview_url: `/api/view?filename=${name}&type=output`,
tags: ['output'],
user_metadata: {
jobId,
nodeId,
subfolder: '',
outputCount,
allOutputs: [
{
filename: name,
subfolder: '',
type: 'output',
nodeId,
mediaType: 'images'
}
]
}
}
}
function mountMultiOutput(
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined
): VueWrapper<MultiOutputInstance> {
return mount(WidgetSelectDropdown, {
props: { widget, modelValue, assetKind: 'image' as const },
global: { plugins: [PrimeVue, createTestingPinia(), i18n] }
}) as unknown as VueWrapper<MultiOutputInstance>
}
const defaultWidget = () =>
createMockWidget<string | undefined>({
value: 'output_001.png',
name: 'test_image',
type: 'combo',
options: { values: [] }
})
beforeEach(() => {
mockMediaAssets.media.value = []
mockResolveOutputAssetItems.mockReset()
})
it('shows all outputs after resolving multi-output jobs', async () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-1', 'preview.png', '5', 3)
]
mockResolveOutputAssetItems.mockResolvedValue([
{
id: 'job-1-5-output_001.png',
name: 'output_001.png',
preview_url: '/api/view?filename=output_001.png&type=output',
tags: ['output']
},
{
id: 'job-1-5-output_002.png',
name: 'output_002.png',
preview_url: '/api/view?filename=output_002.png&type=output',
tags: ['output']
},
{
id: 'job-1-5-output_003.png',
name: 'output_003.png',
preview_url: '/api/view?filename=output_003.png&type=output',
tags: ['output']
}
])
const wrapper = mountMultiOutput(defaultWidget(), 'output_001.png')
await vi.waitFor(() => {
expect(wrapper.vm.outputItems).toHaveLength(3)
})
expect(wrapper.vm.outputItems.map((i) => i.name)).toEqual([
'output_001.png [output]',
'output_002.png [output]',
'output_003.png [output]'
])
})
it('shows preview output when job has only one output', () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-2', 'single.png', '3', 1)
]
const widget = createMockWidget<string | undefined>({
value: 'single.png',
name: 'test_image',
type: 'combo',
options: { values: [] }
})
const wrapper = mountMultiOutput(widget, 'single.png')
expect(wrapper.vm.outputItems).toHaveLength(1)
expect(wrapper.vm.outputItems[0].name).toBe('single.png [output]')
expect(mockResolveOutputAssetItems).not.toHaveBeenCalled()
})
it('resolves two multi-output jobs independently', async () => {
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-A', 'previewA.png', '1', 2),
makeMultiOutputAsset('job-B', 'previewB.png', '2', 2)
]
mockResolveOutputAssetItems.mockImplementation(async (meta) => {
if (meta.jobId === 'job-A') {
return [
{ id: 'A-1', name: 'a1.png', preview_url: '', tags: ['output'] },
{ id: 'A-2', name: 'a2.png', preview_url: '', tags: ['output'] }
]
}
return [
{ id: 'B-1', name: 'b1.png', preview_url: '', tags: ['output'] },
{ id: 'B-2', name: 'b2.png', preview_url: '', tags: ['output'] }
]
})
const wrapper = mountMultiOutput(defaultWidget(), undefined)
await vi.waitFor(() => {
expect(wrapper.vm.outputItems).toHaveLength(4)
})
const names = wrapper.vm.outputItems.map((i) => i.name)
expect(names).toContain('a1.png [output]')
expect(names).toContain('a2.png [output]')
expect(names).toContain('b1.png [output]')
expect(names).toContain('b2.png [output]')
})
it('resolves outputs when allOutputs already contains all items', async () => {
mockMediaAssets.media.value = [
{
id: 'job-complete',
name: 'preview.png',
preview_url: '/api/view?filename=preview.png&type=output',
tags: ['output'],
user_metadata: {
jobId: 'job-complete',
nodeId: '1',
subfolder: '',
outputCount: 2,
allOutputs: [
{
filename: 'out1.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
},
{
filename: 'out2.png',
subfolder: '',
type: 'output',
nodeId: '1',
mediaType: 'images'
}
]
}
}
]
mockResolveOutputAssetItems.mockResolvedValue([
{ id: 'c-1', name: 'out1.png', preview_url: '', tags: ['output'] },
{ id: 'c-2', name: 'out2.png', preview_url: '', tags: ['output'] }
])
const wrapper = mountMultiOutput(defaultWidget(), undefined)
await vi.waitFor(() => {
expect(wrapper.vm.outputItems).toHaveLength(2)
})
expect(mockResolveOutputAssetItems).toHaveBeenCalledWith(
expect.objectContaining({ jobId: 'job-complete' }),
expect.any(Object)
)
const names = wrapper.vm.outputItems.map((i) => i.name)
expect(names).toEqual(['out1.png [output]', 'out2.png [output]'])
})
it('falls back to preview when resolver rejects', async () => {
const consoleWarnSpy = vi
.spyOn(console, 'warn')
.mockImplementation(() => {})
mockMediaAssets.media.value = [
makeMultiOutputAsset('job-fail', 'preview.png', '1', 3)
]
mockResolveOutputAssetItems.mockRejectedValue(new Error('network error'))
const wrapper = mountMultiOutput(defaultWidget(), undefined)
await vi.waitFor(() => {
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Failed to resolve multi-output job',
'job-fail',
expect.any(Error)
)
})
expect(wrapper.vm.outputItems).toHaveLength(1)
expect(wrapper.vm.outputItems[0].name).toBe('preview.png [output]')
consoleWarnSpy.mockRestore()
})
})
describe('WidgetSelectDropdown undo tracking', () => {
interface UndoTrackingInstance extends ComponentPublicInstance {
updateSelectedItems: (selectedSet: Set<string>) => void

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { capitalize } from 'es-toolkit'
import { computed, provide, ref, shallowRef, toRef, watch } from 'vue'
import { computed, provide, ref, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
@@ -31,9 +31,6 @@ import type {
} from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import { useAssetWidgetData } from '@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import type { ResultItemType } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -156,82 +153,24 @@ function assetKindToMediaType(kind: AssetKind): string {
return kind === 'mesh' ? '3D' : kind
}
/**
* Per-job cache of resolved outputs for multi-output jobs.
* Keyed by jobId, populated lazily via resolveOutputAssetItems which
* fetches full outputs through getJobDetail (itself LRU-cached).
*/
const resolvedByJobId = shallowRef(new Map<string, AssetItem[]>())
const pendingJobIds = new Set<string>()
watch(
() => outputMediaAssets.media.value,
(assets, _, onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true
})
pendingJobIds.clear()
for (const asset of assets) {
const meta = getOutputAssetMetadata(asset.user_metadata)
if (!meta) continue
const outputCount = meta.outputCount ?? meta.allOutputs?.length ?? 0
if (
outputCount <= 1 ||
resolvedByJobId.value.has(meta.jobId) ||
pendingJobIds.has(meta.jobId)
)
continue
pendingJobIds.add(meta.jobId)
void resolveOutputAssetItems(meta, { createdAt: asset.created_at })
.then((resolved) => {
if (cancelled || !resolved.length) return
const next = new Map(resolvedByJobId.value)
next.set(meta.jobId, resolved)
resolvedByJobId.value = next
})
.catch((error) => {
console.warn('Failed to resolve multi-output job', meta.jobId, error)
})
.finally(() => {
pendingJobIds.delete(meta.jobId)
})
}
},
{ immediate: true }
)
const outputItems = computed<FormDropdownItem[]>(() => {
if (!['image', 'video', 'audio', 'mesh'].includes(props.assetKind ?? ''))
return []
const targetMediaType = assetKindToMediaType(props.assetKind!)
const seen = new Set<string>()
const items: FormDropdownItem[] = []
const outputFiles = outputMediaAssets.media.value.filter(
(asset) => getMediaTypeFromFilename(asset.name) === targetMediaType
)
const assets = outputMediaAssets.media.value.flatMap((asset) => {
const meta = getOutputAssetMetadata(asset.user_metadata)
const resolved = meta ? resolvedByJobId.value.get(meta.jobId) : undefined
return resolved ?? [asset]
})
for (const asset of assets) {
if (getMediaTypeFromFilename(asset.name) !== targetMediaType) continue
if (seen.has(asset.id)) continue
seen.add(asset.id)
return outputFiles.map((asset) => {
const annotatedPath = `${asset.name} [output]`
items.push({
return {
id: `output-${annotatedPath}`,
preview_url: asset.preview_url || getMediaUrl(asset.name, 'output'),
name: annotatedPath,
label: getDisplayLabel(annotatedPath)
})
}
return items
}
})
})
/**

View File

@@ -415,7 +415,6 @@ const zSettings = z.object({
'Comfy.Canvas.LeftMouseClickBehavior': z.string(),
'Comfy.Canvas.MouseWheelScroll': z.string(),
'Comfy.VueNodes.Enabled': z.boolean(),
'Comfy.AppBuilder.VueNodeSwitchDismissed': z.boolean(),
'Comfy.VueNodes.AutoScaleLayout': z.boolean(),
'Comfy.Assets.UseAssetAPI': z.boolean(),
'Comfy.Queue.QPOV2': z.boolean(),

View File

@@ -110,7 +110,7 @@ interface Load3DNode extends LGraphNode {
const viewerInstances = new Map<NodeId, ReturnType<UseLoad3dViewerFn>>()
class Load3dService {
export class Load3dService {
private static instance: Load3dService
private constructor() {}

View File

@@ -47,24 +47,6 @@ vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
useEmptyWorkflowDialog: () => mockEmptyWorkflowDialog
}))
const mockSettings = vi.hoisted(() => {
const store: Record<string, unknown> = {}
return {
store,
get: vi.fn((key: string) => store[key] ?? false),
set: vi.fn(async (key: string, value: unknown) => {
store[key] = value
}),
reset() {
for (const key of Object.keys(store)) delete store[key]
}
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettings
}))
import { useAppModeStore } from './appModeStore'
function createBuilderWorkflow(
@@ -90,7 +72,6 @@ describe('appModeStore', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.mocked(app.rootGraph).extra = {}
mockResolveNode.mockReturnValue(undefined)
mockSettings.reset()
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
workflowStore = useWorkflowStore()
store = useAppModeStore()
@@ -345,69 +326,4 @@ describe('appModeStore', () => {
})
})
})
describe('autoEnableVueNodes', () => {
it('enables Vue nodes when entering select mode with them disabled', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
await nextTick()
expect(mockSettings.set).toHaveBeenCalledWith(
'Comfy.VueNodes.Enabled',
true
)
})
it('does not enable Vue nodes when already enabled', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = true
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
await nextTick()
expect(mockSettings.set).not.toHaveBeenCalledWith(
'Comfy.VueNodes.Enabled',
expect.anything()
)
})
it('shows popup when Vue nodes are switched on and not dismissed', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false
mockSettings.store['Comfy.AppBuilder.VueNodeSwitchDismissed'] = false
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
await nextTick()
expect(store.showVueNodeSwitchPopup).toBe(true)
})
it('does not show popup when previously dismissed', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false
mockSettings.store['Comfy.AppBuilder.VueNodeSwitchDismissed'] = true
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
await nextTick()
expect(store.showVueNodeSwitchPopup).toBe(false)
})
it('does not enable Vue nodes when entering builder:arrange', async () => {
mockSettings.store['Comfy.VueNodes.Enabled'] = false
workflowStore.activeWorkflow = createBuilderWorkflow('app')
store.selectedOutputs.push(1)
store.enterBuilder()
await nextTick()
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:arrange')
expect(mockSettings.set).not.toHaveBeenCalledWith(
'Comfy.VueNodes.Enabled',
expect.anything()
)
})
})
})

View File

@@ -6,7 +6,6 @@ import { useEmptyWorkflowDialog } from '@/components/builder/useEmptyWorkflowDia
import { useAppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -22,13 +21,10 @@ export function nodeTypeValidForApp(type: string) {
export const useAppModeStore = defineStore('appMode', () => {
const { getCanvas } = useCanvasStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const { mode, setMode, isBuilderMode, isSelectMode } = useAppMode()
const emptyWorkflowDialog = useEmptyWorkflowDialog()
const showVueNodeSwitchPopup = ref(false)
const selectedInputs = ref<[NodeId, string][]>([])
const selectedOutputs = ref<NodeId[]>([])
const hasOutputs = computed(() => !!selectedOutputs.value.length)
@@ -93,33 +89,17 @@ export const useAppModeStore = defineStore('appMode', () => {
{ deep: true }
)
let unwatchReadOnly: (() => void) | undefined
function enforceReadOnly(inSelect: boolean) {
let unwatch: () => void | undefined
watch(isSelectMode, (inSelect) => {
const { state } = getCanvas()
if (!state) return
state.readOnly = inSelect
unwatchReadOnly?.()
unwatch?.()
if (inSelect)
unwatchReadOnly = watch(
unwatch = watch(
() => state.readOnly,
() => (state.readOnly = true)
)
}
function autoEnableVueNodes(inSelect: boolean) {
if (!inSelect) return
if (!settingStore.get('Comfy.VueNodes.Enabled')) {
void settingStore.set('Comfy.VueNodes.Enabled', true)
if (!settingStore.get('Comfy.AppBuilder.VueNodeSwitchDismissed')) {
showVueNodeSwitchPopup.value = true
}
}
}
watch(isSelectMode, (inSelect) => {
enforceReadOnly(inSelect)
autoEnableVueNodes(inSelect)
})
function enterBuilder() {
@@ -166,7 +146,6 @@ export const useAppModeStore = defineStore('appMode', () => {
removeSelectedInput,
resetSelectedToWorkflow,
selectedInputs,
selectedOutputs,
showVueNodeSwitchPopup
selectedOutputs
}
})

View File

@@ -73,7 +73,6 @@ const PROVIDER_COLORS: Record<string, string | [string, string]> = {
'moonvalley-marey': '#DAD9C5',
openai: '#B6B6B6',
pixverse: ['#B465E6', '#E8632A'],
'quiver-ai': '#B6B6B6',
recraft: '#B6B6B6',
reve: '#B6B6B6',
rodin: '#F7F7F7',

View File

@@ -1,49 +0,0 @@
import type { RenderResult } from '@testing-library/vue'
import { render } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import type { ComponentMountingOptions } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import PrimeVue from 'primevue/config'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
function createDefaultPlugins() {
return [
PrimeVue,
createTestingPinia({ stubActions: false }),
createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
]
}
function renderWithDefaults<C>(
component: C,
options?: ComponentMountingOptions<C> & { setupUser?: boolean }
): RenderResult & { user: ReturnType<typeof userEvent.setup> | undefined } {
const { setupUser = true, global: globalOptions, ...rest } = options ?? {}
const user = setupUser ? userEvent.setup() : undefined
const result = render(
component as Parameters<typeof render>[0],
{
global: {
plugins: [...createDefaultPlugins(), ...(globalOptions?.plugins ?? [])],
stubs: globalOptions?.stubs,
directives: globalOptions?.directives
},
...rest
} as Parameters<typeof render>[1]
)
return {
...result,
user
}
}
export { renderWithDefaults as render }
export { screen } from '@testing-library/vue'

View File

@@ -30,11 +30,7 @@
"@tests-ui/*": ["./tests-ui/*"]
},
"typeRoots": ["src/types", "node_modules/@types", "./node_modules"],
"types": [
"vitest/globals",
"@webgpu/types",
"@testing-library/jest-dom/vitest"
],
"types": ["vitest/globals", "@webgpu/types"],
"outDir": "./dist",
"rootDir": "./"
},

View File

@@ -1,4 +1,3 @@
import '@testing-library/jest-dom/vitest'
import { vi } from 'vitest'
import 'vue'