Compare commits

..

9 Commits

Author SHA1 Message Date
PabloWiedemann
714a11872f feat: add outline button variant
Add a new `outline` variant to the Button component with transparent
background, subtle border stroke, and hover state. Automatically
reflected in Storybook via the existing AllVariants story.
2026-03-21 17:35:50 -07:00
Simon Pinfold
aa407e7cd4 fix: show all outputs in FormDropdown for multi-output jobs (#10131)
## Summary

FormDropdown Outputs tab only showed the first output for multi-output
jobs because the Jobs API `/jobs` returns a single `preview_output` per
job.

## Changes

- **What**: When history assets include jobs with `outputs_count > 1`,
lazily fetch full outputs via `getJobDetail` (cached in
`jobOutputCache`) and expand them into individual dropdown items.
Single-output jobs are unaffected. Added in-flight guard to prevent
duplicate fetches.
- This is a consumer-side workaround in `WidgetSelectDropdown.vue` that
becomes a no-op once the backend returns all outputs in the list
response (planned Assets API migration).

## Review Focus

- The `resolvedMultiOutputs` shallowRef + watch pattern for async data
feeding into a computed. Each `getJobDetail` call is cached by
`jobOutputCache` LRU, so no redundant network requests.
- This fix is intentionally temporary — it will be superseded when
OSS/cloud both return full outputs from list endpoints.

## No E2E test

E2E coverage is impractical here: reproducing requires a running ComfyUI
backend executing a workflow that produces multiple outputs, then
inspecting the FormDropdown's Outputs tab. The unit test covers the
lazy-loading logic with mocked `getJobDetail` responses.

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-03-21 16:40:30 -07:00
Dante
a9dce7aa20 fix: prevent survey popup when surveyed feature is inactive (#10360)
## Summary

Prevent the nightly survey popup from appearing when the feature being
surveyed is currently disabled.

## Root Cause

`useSurveyEligibility` checks the feature usage count from localStorage
but does not verify whether the surveyed feature is currently active.
When a user uses the new node search 3+ times (reaching the survey
threshold), then switches back to legacy search, the usage count
persists in localStorage and the survey popup still appears despite the
feature being off.

## Steps to Reproduce

1. Enable new node search (Settings > Node Search Box Implementation >
"default")
2. Use node search 3+ times (double-click canvas, search for nodes)
3. Switch back to legacy search (Settings > Node Search Box
Implementation > "litegraph (legacy)")
4. Wait 5 seconds on a nightly localhost build
5. **Expected**: No survey popup appears (feature is disabled)
6. **Actual**: Survey popup appears because eligibility only checks
stored usage count, not current feature state

## Changes

1. Added optional `isFeatureActive` callback to `FeatureSurveyConfig`
interface
2. Added `isFeatureActive` guard in `useSurveyEligibility` eligibility
computation
3. Configured `node-search` survey with `isFeatureActive` that checks
the current search box setting

## Red-Green Verification

| Commit | Result | Description |
|---|---|---|
| `99e7d7fe9` `test:` | RED | Asserts `isEligible` is false when
`isFeatureActive` returns false. Fails on current code. |
| `01df9af12` `fix:` | GREEN | Adds `isFeatureActive` check to
eligibility. Test passes. |

Fixes #10333
2026-03-22 07:38:41 +09:00
Alexander Brown
20738b6349 test: add Vue Testing Library infrastructure and pilot migration (#10319)
## Summary

Add Vue Testing Library (VTL) infrastructure and pilot-migrate
ComfyQueueButton.test.ts as Phase 0 of an incremental VTL adoption.

## Changes

- **What**: Install `@testing-library/vue`,
`@testing-library/user-event`, `@testing-library/jest-dom`, and
`eslint-plugin-testing-library`. Configure jest-dom matchers globally
via `vitest.setup.ts` and `tsconfig.json`. Create shared render wrapper
at `src/utils/test-utils.ts` (pre-configures PrimeVue, Pinia, i18n).
Migrate `ComfyQueueButton.test.ts` from `@vue/test-utils` to VTL. Add
warn-level `testing-library/*` ESLint rules for test files.
- **Dependencies**: `@testing-library/vue`,
`@testing-library/user-event`, `@testing-library/jest-dom`,
`eslint-plugin-testing-library`

## Review Focus

- `src/utils/test-utils.ts` — shared render wrapper typing approach
(uses `ComponentMountingOptions` from VTU since VTL's `RenderOptions`
requires a generic parameter)
- ESLint rules are all set to `warn` during migration to avoid breaking
existing VTU tests
- VTL coexists with VTU — no existing tests are broken

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10319-test-add-Vue-Testing-Library-infrastructure-and-pilot-migration-3286d73d3650812793ccd8a839550a04)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-21 13:40:15 -07:00
Alexander Brown
477f9c7631 chore: upgrade nx 22.5.2 → 22.6.1 (#10370)
## Summary

Upgrade Nx from 22.5.2 to 22.6.1.

## Changes

- **What**: Bumped nx, @nx/eslint, @nx/playwright, @nx/storybook, and
@nx/vite from 22.5.2 to 22.6.1.
- **Dependencies**: nx, @nx/eslint, @nx/playwright, @nx/storybook,
@nx/vite updated to 22.6.1.

## Review Focus

All Nx migrations ran with no changes needed — this is a straightforward
version bump.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10370-chore-upgrade-nx-22-5-2-22-6-1-32a6d73d36508191988bdc7376dc5e14)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
2026-03-21 17:41:36 +00:00
pythongosssss
dee494f019 feat: App mode - Switch to Nodes 2.0 when entering builder (#10337)
## Summary

We've had some reports of issues selecting inputs/nodes when trying to
use the builder in LiteGraph mode and due to the complexity of the
canvas system, we're going to enable Nodes 2.0 when entering the builder
to ensure the best experience.

## Changes

- **What**:  
- When entering builder select mode automatically switch to Nodes 2.0
- Extract reusable component from features toast
- Show popup telling user the mode was changed
- Add hidden setting for storing "don't show again" on the switch popup

## Review Focus
- I have not removed the LiteGraph selection code in case someone still
manages to enter the builder in LiteGraph mode, this should be cleaned
up in future

## Screenshots (if applicable)

<img width="423" height="224" alt="image"
src="https://github.com/user-attachments/assets/cc2591bc-e5dc-47ef-a3c6-91ca7b6066ff"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10337-feat-App-mode-Switch-to-Nodes-2-0-when-entering-builder-3296d73d3650818e9f3cdaac59d15609)
by [Unito](https://www.unito.io)
2026-03-21 10:38:59 -07:00
pythongosssss
39864b67d8 feat: App mode - Show filename on previews (#10364)
## Summary

Shows the name of the file allowing users to update the filename_prefix
widget on nodes to easier identify which output is for which node

## Changes

- **What**: Pass output display name or filename and use as label

## Screenshots (if applicable)

<img width="586" height="203" alt="image"
src="https://github.com/user-attachments/assets/8d989e61-aa47-4644-8738-159dbf4430e0"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10364-feat-App-mode-Show-filename-on-previews-32a6d73d36508133b53df619e8f0bea1)
by [Unito](https://www.unito.io)
2026-03-21 10:28:24 -07:00
Alexander Brown
32bd570855 chore: clean up knip config, upgrade to v6, remove unused exports (#10348)
## Summary

Clean up stale knip configuration, upgrade to v6, and remove unused
exports.

## Changes

- **What**: Upgrade knip 5.75.1 → 6.0.1; remove 13 stale/redundant
config entries; remove custom CSS compiler (replaced by v6 built-in);
move CSS plugin deps to design-system package; fix husky pre-commit
binary detection; remove 3 unused exports (`MaintenanceTaskRunner`,
`ClipspaceDialog`, `Load3dService`)
- **Dependencies**: knip ^5.75.1 → ^6.0.1; tailwindcss-primeui and
tw-animate-css moved from root to packages/design-system

## Review Focus

- The husky pre-commit change from `pnpm exec lint-staged` to `npx
--no-install lint-staged` works around a knip script parser limitation
([knip#743](https://github.com/webpro-nl/knip/issues/743))
- knip v6 drops Node.js 18 support (requires ≥20.19.0) and removes
`classMembers` issue type

Co-authored-by: Amp <amp@ampcode.com>
2026-03-21 09:43:14 -07:00
Yourz
811d58aef7 feat: enable Quiver AI icon for partner nodes (#10366)
## Summary

<!-- One sentence describing what changed and why. -->

Enable Quiver AI icon for partner nodes

## Changes

- **What**: <!-- Core functionality added/modified -->
  - Enable  Quiver AI icon for partner nodes

No Quiver AI nodes now, just mock the data to see how it will look
<img width="1062" height="926" alt="image"
src="https://github.com/user-attachments/assets/1b81a1cc-d72c-413d-bd75-a63925c27a4b"
/>


## Review Focus
When Quiver AI nodes provided, the icon should show at both node library
tree, but also the PreviewCard label.


<!-- Critical design decisions or edge cases that need attention -->

<!-- If this PR fixes an issue, uncomment and update the line below -->
<!-- Fixes #ISSUE_NUMBER -->

## Screenshots (if applicable)

<!-- Add screenshots or video recording to help explain your changes -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10366-feat-enable-Quiver-AI-icon-for-partner-nodes-32a6d73d365081d4801ec2619bd2c77c)
by [Unito](https://www.unito.io)
2026-03-21 23:29:09 +08:00
68 changed files with 2218 additions and 1113 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. */
export class MaintenanceTaskRunner {
class MaintenanceTaskRunner {
constructor(readonly task: MaintenanceTask) {}
private _state?: MaintenanceTaskState

View File

@@ -281,6 +281,14 @@ 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,10 +30,18 @@ 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()
await page.mouse.click(seedPos.x, seedPos.y)
const titleHeight = await page.evaluate(
() => window.LiteGraph!['NODE_TITLE_HEIGHT'] as number
)
await page.mouse.click(seedPos.x, seedPos.y + titleHeight)
await comfyPage.nextFrame()
// Select an output node
@@ -48,9 +56,15 @@ async function setupSubgraphBuilder(comfyPage: ComfyPage) {
)
)
const saveImageRef = await comfyPage.nodeOps.getNodeRefById(saveImageNodeId)
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 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
)
await comfyPage.nextFrame()
return subgraphNode
@@ -80,6 +94,10 @@ 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,6 +5,7 @@ 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'
@@ -271,6 +272,20 @@ 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,7 +6,6 @@ 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'
@@ -14,25 +13,23 @@ const config: KnipConfig = {
project: ['**/*.{js,ts,vue}', '*.{js,ts,mts}', '!.claude/**']
},
'apps/desktop-ui': {
entry: ['src/main.ts', 'src/i18n.ts'],
entry: ['src/i18n.ts'],
project: ['src/**/*.{js,ts,vue}']
},
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']
},
'packages/shared-frontend-utils': {
project: ['src/**/*.{js,ts}'],
entry: ['src/formatUtil.ts', 'src/networkUtil.ts']
project: ['src/**/*.{js,ts}']
},
'packages/registry-types': {
project: ['src/**/*.{js,ts}']
},
'packages/ingest-types': {
project: ['src/**/*.{js,ts}'],
entry: ['src/index.ts']
project: ['src/**/*.{js,ts}']
}
},
ignoreBinaries: ['python3', 'gh', 'generate'],
ignoreBinaries: ['python3'],
ignoreDependencies: [
// Weird importmap things
'@iconify-json/lucide',
@@ -40,19 +37,12 @@ const config: KnipConfig = {
'@primeuix/forms',
'@primeuix/styled',
'@primeuix/utils',
'@primevue/icons',
// Used by lucideStrokePlugin.js (CSS @plugin)
'@iconify/utils'
'@primevue/icons'
],
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
@@ -60,17 +50,8 @@ 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',
// Loaded via @plugin directive in CSS, not detected by knip
'packages/design-system/src/css/lucideStrokePlugin.js'
'.agents/checks/eslint.strict.config.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}': (stagedFiles: string[]) => {
'./**/*.{ts,tsx,vue,mts,json,yaml}': (stagedFiles: string[]) => {
const commands = [...formatAndEslint(stagedFiles), 'pnpm typecheck']
const hasBrowserTestsChanges = stagedFiles

View File

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

View File

@@ -135,6 +135,9 @@
"@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:",
@@ -153,6 +156,7 @@
"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:",
@@ -177,9 +181,7 @@
"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,7 +12,9 @@
"dependencies": {
"@iconify-json/lucide": "catalog:",
"@iconify/tailwind4": "catalog:",
"@iconify/utils": "catalog:"
"@iconify/utils": "catalog:",
"tailwindcss-primeui": "catalog:",
"tw-animate-css": "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}]");
@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}]");
/* 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

@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 534 B

1071
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.5.2
'@nx/playwright': 22.5.2
'@nx/storybook': 22.5.2
'@nx/vite': 22.5.2
'@nx/eslint': 22.6.1
'@nx/playwright': 22.6.1
'@nx/storybook': 22.6.1
'@nx/vite': 22.6.1
'@pinia/testing': ^1.0.3
'@playwright/test': ^1.58.1
'@primeuix/forms': 0.0.2
@@ -34,6 +34,9 @@ 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
@@ -67,6 +70,7 @@ 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
@@ -79,11 +83,11 @@ catalog:
jsdom: ^27.4.0
jsonata: ^2.1.0
jsondiffpatch: ^0.7.3
knip: ^5.75.1
knip: ^6.0.1
lint-staged: ^16.2.7
markdown-table: ^3.0.4
mixpanel-browser: ^2.71.0
nx: 22.5.2
nx: 22.6.1
oxfmt: ^0.40.0
oxlint: ^1.55.0
oxlint-tsgolint: ^0.17.0

View File

@@ -1,15 +1,12 @@
<template>
<TooltipProvider :delay-duration="300" disable-hoverable-content>
<router-view />
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
</TooltipProvider>
<router-view />
<GlobalDialog />
<BlockUI full-screen :blocked="isLoading" />
</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,7 +119,6 @@ function createWrapper({
global: {
plugins: [pinia, i18n],
stubs: {
BaseTooltip: { template: '<slot />' },
SubgraphBreadcrumb: true,
QueueProgressOverlay: true,
QueueInlineProgressSummary: true,
@@ -132,6 +131,9 @@ function createWrapper({
template: '<div />'
},
...stubs
},
directives: {
tooltip: () => {}
}
}
})

View File

@@ -16,23 +16,22 @@
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"
>
<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>
<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>
</div>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
@@ -55,37 +54,29 @@
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<BaseTooltip
<Button
v-if="isCloud && flags.workflowSharingEnabled"
:text="t('actionbar.shareTooltip')"
side="bottom"
v-tooltip.bottom="shareTooltipConfig"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<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
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
<Button
v-if="!isRightSidePanelOpen"
:text="t('rightSidePanel.togglePanel')"
side="bottom"
v-tooltip.bottom="rightSidePanelTooltipConfig"
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<Button
type="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</BaseTooltip>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
</div>
</div>
<ErrorOverlay />
@@ -142,7 +133,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 BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -252,6 +243,12 @@ 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
@@ -261,6 +258,9 @@ 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,7 +36,6 @@ const mountActionbar = (showRunProgressBar: boolean) => {
global: {
plugins: [pinia, i18n],
stubs: {
BaseTooltip: { template: '<slot />' },
ContextMenu: {
name: 'ContextMenu',
template: '<div />'
@@ -51,6 +50,9 @@ const mountActionbar = (showRunProgressBar: boolean) => {
template: '<button type="button">Run</button>'
},
QueueInlineProgress: true
},
directives: {
tooltip: () => {}
}
}
})

View File

@@ -32,49 +32,47 @@
<Suspense @resolve="comfyRunButtonResolved">
<ComfyRunButton />
</Suspense>
<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="
<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">
{{
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">
{{
isQueuePanelV2Enabled
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
</BaseTooltip>
? t('sideToolbar.queueProgressOverlay.viewJobHistory')
: t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
}}
</span>
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
</div>
</Panel>
@@ -110,7 +108,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 BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'
@@ -370,11 +368,16 @@ watch(isDragging, (dragging) => {
}
})
const queueHistoryTooltipText = computed(() =>
t(
isQueuePanelV2Enabled.value
? 'sideToolbar.queueProgressOverlay.viewJobHistory'
: 'sideToolbar.queueProgressOverlay.expandCollapsedQueue'
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(
t(
isQueuePanelV2Enabled.value
? 'sideToolbar.queueProgressOverlay.viewJobHistory'
: 'sideToolbar.queueProgressOverlay.expandCollapsedQueue'
)
)
)
const activeJobsLabel = computed(() => {

View File

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

View File

@@ -0,0 +1,55 @@
<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

@@ -0,0 +1,78 @@
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

@@ -0,0 +1,87 @@
<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,6 +97,7 @@
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
<VueNodeSwitchPopup />
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
canvasStore.canvas to be initialized. -->
@@ -128,6 +129,7 @@ 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,11 +17,7 @@
<!-- Release Notification Toast positioned within canvas area -->
<Teleport to="#graph-canvas-container">
<ReleaseNotificationToast
:class="{
'sidebar-left': sidebarLocation === 'left',
'sidebar-right': sidebarLocation === 'right',
'small-sidebar': isSmall
}"
:position="sidebarLocation === 'right' ? 'bottom-right' : 'bottom-left'"
/>
</Teleport>

View File

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

View File

@@ -2,17 +2,16 @@
<div class="flex items-center gap-1">
<Popover :show-arrow="false">
<template #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>
<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>
</template>
<template #default="{ close }">
<div class="flex min-w-56 flex-col items-stretch font-inter">
@@ -96,7 +95,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 BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -109,6 +108,7 @@ 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,8 +1,9 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import QueueOverlayActive from './QueueOverlayActive.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
const i18n = createI18n({
legacy: false,
@@ -26,6 +27,11 @@ 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"]',
@@ -37,10 +43,6 @@ const COPY = {
viewAllJobs: 'View all jobs'
}
const BaseTooltipStub = {
template: '<slot />'
}
const mountComponent = (props: Record<string, unknown> = {}) =>
mount(QueueOverlayActive, {
props: {
@@ -56,8 +58,8 @@ const mountComponent = (props: Record<string, unknown> = {}) =>
},
global: {
plugins: [i18n],
stubs: {
BaseTooltip: BaseTooltipStub
directives: {
tooltip: tooltipDirectiveStub
}
}
})
@@ -111,4 +113,13 @@ 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,22 +42,18 @@
t('sideToolbar.queueProgressOverlay.running')
}}</span>
</span>
<BaseTooltip
<Button
v-if="runningCount > 0"
:text="t('sideToolbar.queueProgressOverlay.cancelJobTooltip')"
side="top"
v-tooltip.top="cancelJobTooltip"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.interruptAll')"
@click="$emit('interruptAll')"
>
<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>
<i
class="icon-[lucide--x] block size-4 leading-none text-text-primary"
/>
</Button>
</div>
<div class="flex items-center gap-2">
@@ -67,22 +63,18 @@
t('sideToolbar.queueProgressOverlay.queuedSuffix')
}}</span>
</span>
<BaseTooltip
<Button
v-if="queuedCount > 0"
:text="t('sideToolbar.queueProgressOverlay.clearQueueTooltip')"
side="top"
v-tooltip.top="clearQueueTooltip"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
@click="$emit('clearQueued')"
>
<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>
<i
class="icon-[lucide--list-x] block size-4 leading-none text-text-primary"
/>
</Button>
</div>
</div>
@@ -99,10 +91,11 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{
totalProgressStyle: Record<string, string>
@@ -122,4 +115,10 @@ defineEmits<{
}>()
const { t } = useI18n()
const cancelJobTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.cancelJobTooltip'))
)
const clearQueueTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearQueueTooltip'))
)
</script>

View File

@@ -48,9 +48,11 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
}))
import QueueOverlayHeader from './QueueOverlayHeader.vue'
import * as tooltipConfig from '@/composables/useTooltipConfig'
const BaseTooltipStub = {
template: '<slot />'
const tooltipDirectiveStub = {
mounted: vi.fn(),
updated: vi.fn()
}
const mountHeader = (props = {}) =>
@@ -62,9 +64,7 @@ const mountHeader = (props = {}) =>
},
global: {
plugins: [i18n],
stubs: {
BaseTooltip: BaseTooltipStub
}
directives: { tooltip: tooltipDirectiveStub }
}
})
@@ -105,11 +105,14 @@ 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,31 +11,28 @@
<span :class="{ 'opacity-50': queuedCount === 0 }">{{
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
}}</span>
<BaseTooltip
:text="t('sideToolbar.queueProgressOverlay.clearAllJobsTooltip')"
side="top"
<Button
v-tooltip.top="clearAllJobsTooltip"
variant="destructive"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.clearQueued')"
:disabled="queuedCount === 0"
@click="$emit('clearQueued')"
>
<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>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</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 BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
defineProps<{
headerTitle: string
@@ -48,4 +45,7 @@ defineEmits<{
}>()
const { t } = useI18n()
const clearAllJobsTooltip = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearAllJobsTooltip'))
)
</script>

View File

@@ -13,22 +13,18 @@
>
<Popover :show-arrow="false">
<template #button>
<BaseTooltip
:text="t('sideToolbar.queueProgressOverlay.filterBy')"
side="top"
<Button
v-tooltip.top="filterTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.filterJobs')"
>
<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>
<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>
</template>
<template #default="{ close }">
<div class="flex min-w-48 flex-col items-stretch">
@@ -66,22 +62,18 @@
</Popover>
<Popover :show-arrow="false">
<template #button>
<BaseTooltip
:text="t('sideToolbar.queueProgressOverlay.sortBy')"
side="top"
<Button
v-tooltip.top="sortTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.sortJobs')"
>
<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>
<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>
</template>
<template #default="{ close }">
<div class="flex min-w-48 flex-col items-stretch">
@@ -106,20 +98,16 @@
</div>
</template>
</Popover>
<BaseTooltip
<Button
v-if="showAssetsAction"
:text="t('sideToolbar.queueProgressOverlay.showAssets')"
side="top"
v-tooltip.top="showAssetsTooltipConfig"
variant="secondary"
size="icon"
:aria-label="t('sideToolbar.queueProgressOverlay.showAssetsPanel')"
@click="emit('showAssets')"
>
<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>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
</div>
</div>
</template>
@@ -133,7 +121,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 BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
const {
hideShowAssetsAction = false,
@@ -160,6 +148,15 @@ 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,10 +54,6 @@ const i18n = createI18n({
}
})
const BaseTooltipStub = {
template: '<slot />'
}
describe('JobFiltersBar', () => {
it('emits showAssets when the assets icon button is clicked', async () => {
const wrapper = mount(JobFiltersBar, {
@@ -69,9 +65,7 @@ describe('JobFiltersBar', () => {
},
global: {
plugins: [i18n],
stubs: {
BaseTooltip: BaseTooltipStub
}
directives: { tooltip: () => undefined }
}
})
@@ -94,9 +88,7 @@ describe('JobFiltersBar', () => {
},
global: {
plugins: [i18n],
stubs: {
BaseTooltip: BaseTooltipStub
}
directives: { tooltip: () => undefined }
}
})

View File

@@ -124,38 +124,30 @@
key="actions"
class="inline-flex items-center gap-2 pr-1"
>
<BaseTooltip
<Button
v-if="state === 'failed' && computedShowClear"
:text="t('g.delete')"
side="top"
v-tooltip.top="deleteTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="onDeleteClick"
>
<Button
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="onDeleteClick"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
</BaseTooltip>
<BaseTooltip
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="
state !== 'completed' &&
state !== 'running' &&
computedShowClear
"
:text="t('g.cancel')"
side="top"
v-tooltip.top="cancelTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<Button
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</BaseTooltip>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="state === 'completed'"
variant="textonly"
@@ -163,40 +155,32 @@
@click.stop="emit('view')"
>{{ t('menuLabels.View') }}</Button
>
<BaseTooltip
<Button
v-if="showMenu !== undefined ? showMenu : true"
:text="t('g.more')"
side="top"
v-tooltip.top="moreTooltipConfig"
variant="textonly"
size="icon-sm"
:aria-label="t('g.more')"
@click.stop="emit('menu', $event)"
>
<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>
<i class="icon-[lucide--more-horizontal] size-4" />
</Button>
</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 -->
<BaseTooltip
<Button
v-if="state === 'running' && computedShowClear"
:text="t('g.cancel')"
side="top"
v-tooltip.top="cancelTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<Button
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="onCancelClick"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</BaseTooltip>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</div>
@@ -211,7 +195,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 BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
@@ -263,6 +247,10 @@ 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,10 +19,13 @@ 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'
'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'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -51,9 +54,11 @@ const variants = [
'textonly',
'muted-textonly',
'destructive-textonly',
'link',
'base',
'overlay-white',
'gradient'
'gradient',
'outline'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = [
'sm',

View File

@@ -1,55 +0,0 @@
<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

@@ -1,17 +0,0 @@
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

@@ -0,0 +1,18 @@
/**
* 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'
export class ClipspaceDialog extends ComfyDialog {
class ClipspaceDialog extends ComfyDialog {
static items: Array<
HTMLButtonElement & {
contextPredicate?: () => boolean

View File

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

View File

@@ -181,6 +181,17 @@ 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,6 +13,7 @@ export interface FeatureSurveyConfig {
triggerThreshold?: number
delayMs?: number
enabled?: boolean
isFeatureActive?: () => boolean
}
interface SurveyState {
@@ -61,8 +62,13 @@ 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('.icon-\\[lucide--rocket\\]').exists()).toBe(true)
expect(wrapper.find('.release-toast-popup').exists()).toBe(true)
})
it('displays release version', () => {

View File

@@ -1,40 +1,17 @@
<template>
<div v-if="shouldShow" class="release-toast-popup">
<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)]"
<NotificationPopup
icon="icon-[lucide--rocket]"
:title="$t('releaseToast.newVersionAvailable')"
:subtitle="latestRelease?.version"
:position
>
<!-- 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>
<div
class="pl-14 text-sm leading-[1.21] font-normal text-muted-foreground"
v-html="formattedContent"
></div>
<!-- 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">
<template #footer-start>
<a
class="flex items-center gap-2 py-1 text-sm font-normal text-muted-foreground hover:text-base-foreground"
:href="changelogUrl"
@@ -45,22 +22,27 @@
<i class="icon-[lucide--external-link] size-4"></i>
{{ $t('releaseToast.whatsNew') }}
</a>
<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>
</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>
</template>
@@ -69,6 +51,8 @@ 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'
@@ -79,6 +63,10 @@ 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()
@@ -218,23 +206,3 @@ 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,11 +46,12 @@
onThumbnailError($event.name, $event.previewUrl)
"
/>
<BaseTooltip :text="item.name">
<span class="truncate text-xs text-base-foreground">
{{ item.name }}
</span>
</BaseTooltip>
<span
v-tooltip="buildTooltipConfig(item.name)"
class="truncate text-xs text-base-foreground"
>
{{ item.name }}
</span>
<span
v-if="item.in_library"
class="ml-auto shrink-0 text-xs text-muted-foreground"
@@ -76,7 +77,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 BaseTooltip from '@/components/ui/tooltip/BaseTooltip.vue'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
const { items } = defineProps<{
items: AssetInfo[]

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { defineAsyncComponent, useAttrs } from 'vue'
import { computed, defineAsyncComponent, useAttrs } from 'vue'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
@@ -19,40 +19,56 @@ const { output } = defineProps<{
}>()
const attrs = useAttrs()
const mediaType = computed(() => getMediaType(output))
const outputLabel = computed(
() => output.display_name?.trim() || output.filename
)
</script>
<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 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>
</template>

View File

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

View File

@@ -1,71 +1,63 @@
<template>
<div v-if="renderError" class="node-error p-1 text-xs text-red-500"></div>
<BaseTooltip
<div
v-else
:text="inputTooltipText"
side="left"
size="large"
:delay-duration="tooltipDelay"
:disabled="!tooltipsEnabled"
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'
)
"
>
<div
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
: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'
'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'
)
"
>
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
: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(
'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'
'truncate text-node-component-slot-text',
hasError && 'font-medium text-error'
)
"
: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>
>
{{
slotData.label ||
slotData.localized_name ||
(slotData.name ?? `Input ${index}`)
}}
</span>
</div>
</BaseTooltip>
</div>
</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'
@@ -102,13 +94,15 @@ const dotOnly = computed(() => props.dotOnly || hasNoLabel.value)
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
const { getInputSlotTooltip, tooltipsEnabled, tooltipDelay } = useNodeTooltips(
const { getInputSlotTooltip, createTooltipConfig } = useNodeTooltips(
props.nodeType || ''
)
const inputTooltipText = computed(() => {
const tooltipConfig = computed(() => {
const slotName = props.slotData.localized_name || props.slotData.name || ''
return getInputSlotTooltip(slotName) || `Input: ${slotName}`
const tooltipText = getInputSlotTooltip(slotName)
const fallbackText = tooltipText || `Input: ${slotName}`
return createTooltipConfig(fallbackText)
})
onErrorCaptured((error) => {

View File

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

View File

@@ -96,9 +96,11 @@ const createMountConfig = () => {
global: {
plugins: [PrimeVue, i18n, pinia],
components: { InputText },
stubs: {
BaseTooltip: {
template: '<div><slot /></div>'
directives: {
tooltip: {
mounted: vi.fn(),
updated: vi.fn(),
unmounted: vi.fn()
}
}
}
@@ -188,13 +190,56 @@ describe('NodeHeader.vue', () => {
})
describe('Tooltips', () => {
it('renders node title inside a tooltip wrapper', () => {
it('applies tooltip directive to node title with correct configuration', () => {
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,28 +38,21 @@
</Button>
</div>
<!-- Node Title -->
<BaseTooltip
:text="isEditing ? '' : getNodeDescription"
side="top"
size="large"
:delay-duration="tooltipDelay"
:disabled="!tooltipsEnabled"
<div
v-tooltip.top="tooltipConfig"
class="flex min-w-0 flex-1 items-center gap-2"
data-testid="node-title"
>
<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 class="flex-1 truncate">
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
/>
</div>
</BaseTooltip>
</div>
</div>
<template v-for="badge in priceBadges ?? []" :key="badge.required">
@@ -96,7 +89,6 @@ 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'
@@ -134,10 +126,18 @@ onErrorCaptured((error) => {
// Editing state
const isEditing = ref(false)
const { getNodeDescription, tooltipsEnabled, tooltipDelay } = useNodeTooltips(
const { getNodeDescription, createTooltipConfig } = 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,29 +55,22 @@
:name="widget.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<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>
<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"
/>
</AppInput>
</div>
</template>
@@ -85,10 +78,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,
@@ -189,7 +182,7 @@ const showAdvanced = computed(
nodeData?.showAdvanced ||
settingStore.get('Comfy.Node.AlwaysShowAdvancedWidgets')
)
const { getWidgetTooltip, tooltipsEnabled, tooltipDelay } = useNodeTooltips(
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(
nodeType.value
)
const widgetValueStore = useWidgetValueStore()
@@ -224,7 +217,7 @@ interface ProcessedWidget {
name: string
renderKey: string
simplified: SimplifiedWidget
tooltipText: string
tooltipConfig: TooltipOptions
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
@@ -433,6 +426,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
)
const tooltipText = getWidgetTooltip(widget)
const tooltipConfig = createTooltipConfig(tooltipText)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
@@ -460,7 +454,7 @@ const processedWidgets = computed((): ProcessedWidget[] => {
simplified,
value,
updateHandler,
tooltipText,
tooltipConfig,
slotMetadata
})
}

View File

@@ -1,36 +1,27 @@
<template>
<div v-if="renderError" class="node-error p-1 text-xs text-red-500"></div>
<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 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>
</div>
</BaseTooltip>
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
class="w-3 translate-x-1/2"
:slot-data
@pointerdown="onPointerDown"
/>
</div>
</template>
<script setup lang="ts">
@@ -38,7 +29,6 @@ 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'
@@ -75,11 +65,11 @@ const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
const { getOutputSlotTooltip, tooltipsEnabled, tooltipDelay } = useNodeTooltips(
const { getOutputSlotTooltip, createTooltipConfig } = useNodeTooltips(
props.nodeType || ''
)
const outputTooltipText = computed(() => {
const tooltipConfig = computed(() => {
const slotName = props.slotData.name || ''
const tooltipText = getOutputSlotTooltip(props.index)
const fallbackText = tooltipText || `Output: ${slotName}`
@@ -87,7 +77,7 @@ const outputTooltipText = computed(() => {
props.slotData.shape === RenderShape.GRID
? ` ${t('vueNodesSlot.iterative')}`
: ''
return fallbackText + iterativeSuffix
return createTooltipConfig(fallbackText + iterativeSuffix)
})
onErrorCaptured((error) => {

View File

@@ -1,4 +1,8 @@
import { computed, unref } from 'vue'
import type {
TooltipOptions,
TooltipPassThroughMethodOptions
} from 'primevue/tooltip'
import { computed, ref, unref } from 'vue'
import type { MaybeRef } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
@@ -6,6 +10,77 @@ 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
@@ -15,14 +90,15 @@ 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')
)
const tooltipDelay = computed(
() => settingsStore.get('LiteGraph.Node.TooltipDelay') as number
)
// Get node definition for tooltip data
const nodeDef = computed(() => nodeDefStore.nodeDefsByName[unref(nodeType)])
/**
@@ -38,7 +114,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for input slots
*/
function getInputSlotTooltip(slotName: string) {
const getInputSlotTooltip = (slotName: string) => {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(slotName)}.tooltip`
@@ -49,7 +125,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for output slots
*/
function getOutputSlotTooltip(slotIndex: number) {
const getOutputSlotTooltip = (slotIndex: number) => {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.outputs.${slotIndex}.tooltip`
@@ -60,7 +136,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for widgets
*/
function getWidgetTooltip(widget: SafeWidgetData) {
const getWidgetTooltip = (widget: SafeWidgetData) => {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
// First try widget-specific tooltip
@@ -73,12 +149,46 @@ 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
getWidgetTooltip,
createTooltipConfig
}
}

View File

@@ -55,6 +55,33 @@ 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',
@@ -484,6 +511,229 @@ 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, toRef, watch } from 'vue'
import { computed, provide, ref, shallowRef, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
@@ -31,6 +31,9 @@ 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'
@@ -153,24 +156,82 @@ 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 outputFiles = outputMediaAssets.media.value.filter(
(asset) => getMediaTypeFromFilename(asset.name) === targetMediaType
)
const seen = new Set<string>()
const items: FormDropdownItem[] = []
return outputFiles.map((asset) => {
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)
const annotatedPath = `${asset.name} [output]`
return {
items.push({
id: `output-${annotatedPath}`,
preview_url: asset.preview_url || getMediaUrl(asset.name, 'output'),
name: annotatedPath,
label: getDisplayLabel(annotatedPath)
}
})
})
}
return items
})
/**

View File

@@ -415,6 +415,7 @@ 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>>()
export class Load3dService {
class Load3dService {
private static instance: Load3dService
private constructor() {}

View File

@@ -47,6 +47,24 @@ 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(
@@ -72,6 +90,7 @@ 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()
@@ -326,4 +345,69 @@ 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,6 +6,7 @@ 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'
@@ -21,10 +22,13 @@ 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)
@@ -89,17 +93,33 @@ export const useAppModeStore = defineStore('appMode', () => {
{ deep: true }
)
let unwatch: () => void | undefined
watch(isSelectMode, (inSelect) => {
let unwatchReadOnly: (() => void) | undefined
function enforceReadOnly(inSelect: boolean) {
const { state } = getCanvas()
if (!state) return
state.readOnly = inSelect
unwatch?.()
unwatchReadOnly?.()
if (inSelect)
unwatch = watch(
unwatchReadOnly = 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() {
@@ -146,6 +166,7 @@ export const useAppModeStore = defineStore('appMode', () => {
removeSelectedInput,
resetSelectedToWorkflow,
selectedInputs,
selectedOutputs
selectedOutputs,
showVueNodeSwitchPopup
}
})

View File

@@ -73,6 +73,7 @@ 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',

49
src/utils/test-utils.ts Normal file
View File

@@ -0,0 +1,49 @@
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,7 +30,11 @@
"@tests-ui/*": ["./tests-ui/*"]
},
"typeRoots": ["src/types", "node_modules/@types", "./node_modules"],
"types": ["vitest/globals", "@webgpu/types"],
"types": [
"vitest/globals",
"@webgpu/types",
"@testing-library/jest-dom/vitest"
],
"outDir": "./dist",
"rootDir": "./"
},

View File

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