Compare commits

..

6 Commits

Author SHA1 Message Date
Austin Mroz
f39b1dee25 Add asset to type whitelist 2026-06-15 22:13:12 -07:00
Austin Mroz
1b4456da33 Revert flake snapshot 2026-06-15 19:54:54 -07:00
github-actions
82ab1139f1 [automated] Update test expectations 2026-06-16 02:32:45 +00:00
Austin Mroz
d8707de801 Tweak chevron size so it doesn't move 2026-06-15 18:59:02 -07:00
Austin Mroz
d04744f5b1 Add test 2026-06-15 18:45:06 -07:00
Austin Mroz
c36dcb06ae Show value tooltips on some widgets 2026-06-15 18:15:22 -07:00
59 changed files with 599 additions and 1622 deletions

View File

@@ -109,27 +109,3 @@ jobs:
exit 1
fi
echo '✅ No PostHog references found'
- name: Scan dist for Customer.io telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Customer.io references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e 'CustomerIoTelemetryProvider' \
-e '@customerio/cdp-analytics-browser' \
-e 'customerio-gist-web' \
-e '(?i)cdp\.customer\.io' \
-e 'Comfy\.CustomerIo' \
dist; then
echo '❌ ERROR: Customer.io references found in dist assets!'
echo 'Customer.io must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Customer.io references found'

View File

@@ -1,103 +0,0 @@
import { expect } from '@playwright/test'
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
interface UploadResponse {
name: string
subfolder: string
type: 'input' | 'output' | 'temp'
}
const IMAGE_CANVAS_INDEX = 0
const MASK_CANVAS_INDEX = 2
const successResponse = (name: string): UploadResponse => ({
name,
subfolder: 'clipspace',
type: 'input'
})
const fulfillJson = (body: UploadResponse) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
test('Save with drawn mask uploads non-empty mask data', async ({
comfyPage,
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeAndExpectPixels(dialog)
let observedContentType = ''
let observedBodyLength = 0
await comfyPage.page.route('**/upload/mask', async (route) => {
const request = route.request()
observedContentType = (await request.headerValue('content-type')) ?? ''
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
await route.fulfill(
fulfillJson(successResponse('clipspace-mask-123.png'))
)
})
await comfyPage.page.route('**/upload/image', (route) =>
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
)
await dialog.getByRole('button', { name: 'Save' }).click()
await expect(dialog).toBeHidden()
expect(observedContentType).toContain('multipart/form-data')
expect(observedBodyLength).toBeGreaterThan(256)
})
test('Canvas dimensions match the loaded image', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
const imageDimensions =
await maskEditor.getCanvasPixelData(IMAGE_CANVAS_INDEX)
const maskDimensions =
await maskEditor.getCanvasPixelData(MASK_CANVAS_INDEX)
expect(imageDimensions).not.toBeNull()
expect(maskDimensions).not.toBeNull()
expect(imageDimensions?.totalPixels).toBe(64 * 64)
expect(maskDimensions?.totalPixels).toBe(64 * 64)
await expect(dialog).toBeVisible()
})
test('Save failure on partial upload keeps dialog open', async ({
comfyPage,
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeAndExpectPixels(dialog)
// The saver uploads sequentially: mask layer first, then image layers.
// Let the mask upload succeed and the image upload fail to exercise both
// endpoints and verify the dialog stays open after a partial failure.
let maskUploadHit = false
let imageUploadHit = false
await comfyPage.page.route('**/upload/mask', (route) => {
maskUploadHit = true
return route.fulfill(
fulfillJson(successResponse('clipspace-mask-999.png'))
)
})
await comfyPage.page.route('**/upload/image', (route) => {
imageUploadHit = true
return route.fulfill({ status: 500 })
})
const saveButton = dialog.getByRole('button', { name: 'Save' })
await saveButton.click()
await expect.poll(() => maskUploadHit).toBe(true)
await expect.poll(() => imageUploadHit).toBe(true)
await expect(dialog).toBeVisible()
await expect(saveButton).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -131,14 +131,6 @@ test.describe('Node replacement', { tag: ['@node', '@ui'] }, () => {
'normal',
1
])
if (mode.vueNodesEnabled) {
await expect(
comfyPage.vueNodes
.getWidgetByName('KSampler', 'denoise')
.locator('input')
).toHaveValue(/^1(?:\.0+)?$/)
}
})
test('Success toast is shown after replacement', async ({

View File

@@ -177,30 +177,6 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
}).toPass({ timeout: 5000 })
})
test('does not drag contents when control is held', async ({ comfyPage }) => {
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
await expect.poll(groupCount, 'create group').toBe(1)
await comfyPage.page.mouse.click(100, 100)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const initialNodeBounds = await ksampler.boundingBox()
expect(initialNodeBounds).toBeTruthy()
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
await comfyPage.page.mouse.move(groupPos.x, groupPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.mouse.move(groupPos.x + 100, groupPos.y)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Control')
await expect
.poll(() => getGroupTitlePosition(comfyPage, 'Group'))
.not.toEqual(groupPos)
expect(await ksampler.boundingBox()).toEqual(initialNodeBounds)
})
test('should keep groups aligned after loading legacy Vue workflows', async ({
comfyPage
}) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -8,7 +8,6 @@ import {
getPromotedWidgetNames,
getPromotedWidgetCountByName
} from '@e2e/fixtures/utils/promotedWidgets'
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
@@ -140,46 +139,6 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
)
}
)
wstest(
'Displays previews inside subgraphs received while workflow inactive',
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
const previewLocator = comfyPage.vueNodes.getNodeByTitle('Preview Image')
const previewImage = new VueNodeFixture(previewLocator)
const subgraphLocator = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
const subgraphNode = new VueNodeFixture(subgraphLocator)
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Preview Image')
await expect(previewImage.root).toBeVisible()
})
await test.step('Create subgraph', async () => {
await previewImage.title.click()
await comfyPage.page.keyboard.press('Control+Shift+e')
await expect(subgraphNode.root).toBeVisible()
})
await test.step('Inject Previews from different tab', async () => {
const jobId = await execution.run()
await comfyPage.menu.topbar.getTab(0).click()
await comfyPage.vueNodes.waitForNodes(7)
const images = [{ filename: 'example.png', type: 'input' }]
execution.executed(jobId, '2:1', { images })
await comfyPage.nextFrame()
await comfyPage.menu.topbar.getTab(1).click()
await comfyPage.vueNodes.waitForNodes(1)
})
await expect(subgraphNode.imagePreview.locator('img')).toHaveCount(1)
}
)
})
async function countColumns(locator: Locator) {

View File

@@ -0,0 +1,26 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
test.describe('@vue-nodes tooltips', async () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
await comfyPage.settings.setSetting('LiteGraph.Node.TooltipDelay', 0)
})
test('widget value tooltips', async ({ comfyPage }) => {
const tooltip = comfyPage.page.locator('.p-tooltip-text')
await comfyPage.vueNodes.getWidgetByName('load check', 'ckpt_name').hover()
await expect(tooltip, 'displays for combos').toContainText('v1-5-pruned')
await comfyPage.vueNodes.getWidgetByName('ksampler', 'seed').hover()
await expect(tooltip, 'displays for numbers').toContainText('15668')
await comfyPage.vueNodes.getNodeLocator('6').getByLabel('text').hover()
await expect(tooltip).toBeVisible()
await expect(tooltip, "doesn't display for prompts").not.toContainText(
'purple galaxy bottle'
)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

5
global.d.ts vendored
View File

@@ -49,11 +49,6 @@ interface Window {
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Record<string, unknown>
customer_io?: {
write_key?: string
site_id?: string
user_id?: string
}
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

View File

@@ -66,7 +66,6 @@
"@comfyorg/registry-types": "workspace:*",
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@customerio/cdp-analytics-browser": "catalog:",
"@formkit/auto-animate": "catalog:",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",

137
pnpm-lock.yaml generated
View File

@@ -21,9 +21,6 @@ catalogs:
'@comfyorg/comfyui-electron-types':
specifier: 0.6.2
version: 0.6.2
'@customerio/cdp-analytics-browser':
specifier: ^0.5.3
version: 0.5.3
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1
@@ -450,9 +447,6 @@ importers:
'@comfyorg/tailwind-utils':
specifier: workspace:*
version: link:packages/tailwind-utils
'@customerio/cdp-analytics-browser':
specifier: 'catalog:'
version: 0.5.3
'@formkit/auto-animate':
specifier: 'catalog:'
version: 0.9.0
@@ -1447,15 +1441,6 @@ packages:
peerDependencies:
postcss-selector-parser: ^7.0.0
'@customerio/cdp-analytics-browser@0.5.3':
resolution: {integrity: sha512-P4lBz+P2iCekq+DOETiAtSfdMyNVQd7OjXhocjffjPtyBJ0ADhpvYuNZAT9R+q2AFrDTx0m8cuyJAtEG+qiXPQ==}
'@customerio/cdp-analytics-core@0.5.3':
resolution: {integrity: sha512-mjR0dyzsX8UjMAh22bT5ByiIEYwtpnNhc9TlHTk2nGPhFnMctSsn9KuMXD9BmfSFcjdmTPg+iABOq68yyPBPHg==}
'@customerio/jist@0.1.8':
resolution: {integrity: sha512-MPiAm5rxu6+wQiEPwY+nV/5i7y67vJ0TvQpeQrOuATzWC45kgpu4YAJm+RlrpDOq35CK1C3utlPG/wI1F6ycXg==}
'@cyberalien/svg-utils@1.2.15':
resolution: {integrity: sha512-ZbKU6npzW5PNocdoLVJYfKzaP+c/RpT6JUkoaKrW1DOcw6lyXub8XtcNpI3xok6FnyNjS6ZbsrrtjTnS9yeZAQ==}
@@ -2397,14 +2382,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@lukeed/csprng@1.1.0':
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@lukeed/uuid@2.0.1':
resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==}
engines: {node: '>=8'}
'@mdx-js/react@3.1.1':
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
peerDependencies:
@@ -3298,18 +3275,6 @@ packages:
'@rushstack/ts-command-line@5.3.1':
resolution: {integrity: sha512-mid/JIZSJafwy3x9e4v0wVLuAqSSYYErEHV0HXPALYLSBN13YNkR5caOk0hf97lSRKrxhtvQjGaDKSEelR3sMg==}
'@segment/analytics.js-video-plugins@0.2.1':
resolution: {integrity: sha512-lZwCyEXT4aaHBLNK433okEKdxGAuyrVmop4BpQqQSJuRz0DglPZgd9B/XjiiWs1UyOankg2aNYMN3VcS8t4eSQ==}
'@segment/facade@3.4.10':
resolution: {integrity: sha512-xVQBbB/lNvk/u8+ey0kC/+g8pT3l0gCT8O2y9Z+StMMn3KAFAQ9w8xfgef67tJybktOKKU7pQGRPolRM1i1pdA==}
'@segment/isodate-traverse@1.1.1':
resolution: {integrity: sha512-+G6e1SgAUkcq0EDMi+SRLfT48TNlLPF3QnSgFGVs0V9F3o3fq/woQ2rHFlW20W0yy5NnCUH0QGU3Am2rZy/E3w==}
'@segment/isodate@1.0.3':
resolution: {integrity: sha512-BtanDuvJqnACFkeeYje7pWULVv8RgZaqKHWwGFnL/g/TH/CcZjkIVTfGDp/MAxmilYHUkrX70SqwnYSTNEaN7A==}
'@sentry-internal/browser-utils@10.32.1':
resolution: {integrity: sha512-sjLLep1es3rTkbtAdTtdpc/a6g7v7bK5YJiZJsUigoJ4NTiFeMI5uIDCxbH/tjJ1q23YE1LzVn7T96I+qBRjHA==}
engines: {node: '>=18'}
@@ -5072,9 +5037,6 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
customerio-gist-web@3.23.2:
resolution: {integrity: sha512-oCM7WNEx/3cmEG1qQCKWrMwOtU+h41TTKJICNEb7Wj/1jR6+RJsj3b+3N+5u9TxgvUMusmLFvnVvqshU017eHA==}
cva@1.0.0-beta.4:
resolution: {integrity: sha512-F/JS9hScapq4DBVQXcK85l9U91M6ePeXoBMSp7vypzShoefUBxjQTo3g3935PUHgQd+IW77DjbPRIxugy4/GCQ==}
peerDependencies:
@@ -6293,10 +6255,6 @@ packages:
engines: {node: '>=14'}
hasBin: true
js-cookie@3.0.1:
resolution: {integrity: sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==}
engines: {node: '>=12'}
js-cookie@3.0.7:
resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==}
engines: {node: '>=20'}
@@ -6929,9 +6887,6 @@ packages:
resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==}
engines: {node: '>= 10'}
new-date@1.0.3:
resolution: {integrity: sha512-0fsVvQPbo2I18DT2zVHpezmeeNYV2JaJSrseiHLc17GNOxJzUdx5mvSigPu8LtIfZSij5i1wXnXFspEs2CD6hA==}
nlcst-to-string@4.0.0:
resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
@@ -6981,9 +6936,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
obj-case@0.2.1:
resolution: {integrity: sha512-PquYBBTy+Y6Ob/O2574XHhDtHJlV1cJHMCgW+rDRc9J5hhmRelJB3k5dTK/3cVmFVtzvAKuENeuLpoyTzMzkOg==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -7824,9 +7776,6 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
spark-md5@3.0.2:
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
@@ -8226,12 +8175,6 @@ packages:
unescape-js@1.1.4:
resolution: {integrity: sha512-42SD8NOQEhdYntEiUQdYq/1V/YHwr1HLwlHuTJB5InVVdOSbgI6xu8jK5q65yIzuFCfczzyDF/7hbGzVbyCw0g==}
unfetch@3.1.2:
resolution: {integrity: sha512-L0qrK7ZeAudGiKYw6nzFjnJ2D5WHblUBwmHIqtPS6oKUd+Hcpk7/hKsSmcHsTlpd1TbTNsiRBUKRq3bHLNIqIw==}
unfetch@4.2.0:
resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@@ -8417,10 +8360,6 @@ packages:
resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==}
hasBin: true
uuid@14.0.0:
resolution: {integrity: sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==}
hasBin: true
valibot@1.2.0:
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
peerDependencies:
@@ -8701,8 +8640,8 @@ packages:
vue-component-type-helpers@3.3.2:
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
vue-component-type-helpers@3.3.5:
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
vue-component-type-helpers@3.3.4:
resolution: {integrity: sha512-joip1uZTaQR0nD23N400gIdJ7xY+WiiiMA/BCKz842gvGBknqDQAzklUvDEhqFvvrhQY8S2ZANBMu4X70VMFGw==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
@@ -9551,30 +9490,6 @@ snapshots:
dependencies:
postcss-selector-parser: 7.1.1
'@customerio/cdp-analytics-browser@0.5.3':
dependencies:
'@customerio/cdp-analytics-core': 0.5.3
'@lukeed/uuid': 2.0.1
'@segment/analytics.js-video-plugins': 0.2.1
'@segment/facade': 3.4.10
customerio-gist-web: 3.23.2
dset: 3.1.4
js-cookie: 3.0.1
node-fetch: 2.7.0
spark-md5: 3.0.2
tslib: 2.8.1
unfetch: 4.2.0
transitivePeerDependencies:
- encoding
'@customerio/cdp-analytics-core@0.5.3':
dependencies:
'@lukeed/uuid': 2.0.1
dset: 3.1.4
tslib: 2.8.1
'@customerio/jist@0.1.8': {}
'@cyberalien/svg-utils@1.2.15':
dependencies:
'@iconify/types': 2.0.0
@@ -10535,12 +10450,6 @@ snapshots:
- ws
- zod
'@lukeed/csprng@1.1.0': {}
'@lukeed/uuid@2.0.1':
dependencies:
'@lukeed/csprng': 1.1.0
'@mdx-js/react@3.1.1(@types/react@19.1.9)(react@19.2.4)':
dependencies:
'@types/mdx': 2.0.13
@@ -11170,23 +11079,6 @@ snapshots:
transitivePeerDependencies:
- '@types/node'
'@segment/analytics.js-video-plugins@0.2.1':
dependencies:
unfetch: 3.1.2
'@segment/facade@3.4.10':
dependencies:
'@segment/isodate-traverse': 1.1.1
inherits: 2.0.4
new-date: 1.0.3
obj-case: 0.2.1
'@segment/isodate-traverse@1.1.1':
dependencies:
'@segment/isodate': 1.0.3
'@segment/isodate@1.0.3': {}
'@sentry-internal/browser-utils@10.32.1':
dependencies:
'@sentry/core': 10.32.1
@@ -11431,7 +11323,7 @@ snapshots:
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
type-fest: 2.19.0
vue: 3.5.34(typescript@5.9.3)
vue-component-type-helpers: 3.3.5
vue-component-type-helpers: 3.3.4
'@swc/helpers@0.5.21':
dependencies:
@@ -13211,11 +13103,6 @@ snapshots:
csstype@3.2.3: {}
customerio-gist-web@3.23.2:
dependencies:
'@customerio/jist': 0.1.8
uuid: 14.0.0
cva@1.0.0-beta.4(typescript@5.9.3):
dependencies:
clsx: 2.1.1
@@ -14588,8 +14475,6 @@ snapshots:
js-cookie: 3.0.7
nopt: 7.2.1
js-cookie@3.0.1: {}
js-cookie@3.0.7: {}
js-stringify@1.0.2: {}
@@ -15396,10 +15281,6 @@ snapshots:
neotraverse@0.6.18: {}
new-date@1.0.3:
dependencies:
'@segment/isodate': 1.0.3
nlcst-to-string@4.0.0:
dependencies:
'@types/nlcst': 2.0.3
@@ -15442,8 +15323,6 @@ snapshots:
pathe: 2.0.3
tinyexec: 1.0.4
obj-case@0.2.1: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -16561,8 +16440,6 @@ snapshots:
space-separated-tokens@2.0.2: {}
spark-md5@3.0.2: {}
speakingurl@14.0.1: {}
sprintf-js@1.0.3: {}
@@ -16977,10 +16854,6 @@ snapshots:
dependencies:
string.fromcodepoint: 0.2.1
unfetch@3.1.2: {}
unfetch@4.2.0: {}
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@@ -17171,8 +17044,6 @@ snapshots:
uuid@11.1.1: {}
uuid@14.0.0: {}
valibot@1.2.0(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
@@ -17598,7 +17469,7 @@ snapshots:
vue-component-type-helpers@3.3.2: {}
vue-component-type-helpers@3.3.5: {}
vue-component-type-helpers@3.3.4: {}
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
dependencies:

View File

@@ -15,7 +15,6 @@ catalog:
'@astrojs/sitemap': ^3.7.3
'@astrojs/vue': ^6.0.1
'@comfyorg/comfyui-electron-types': 0.6.2
'@customerio/cdp-analytics-browser': ^0.5.3
'@eslint/js': ^10.0.1
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178

View File

@@ -1,71 +0,0 @@
<template>
<section :class="cn('group flex min-w-0 flex-col py-2', className)">
<div class="flex min-h-8 w-full items-center gap-2 px-3">
<button
type="button"
class="focus-visible:ring-ring flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-sm border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-1"
:aria-expanded="!collapse"
:aria-controls="bodyId"
@click="collapse = !collapse"
>
<span
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full bg-destructive-background-hover px-1 text-2xs/none font-semibold text-white tabular-nums"
>
{{ count }}
</span>
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
{{ title }}
</span>
</button>
<slot name="actions" />
<button
type="button"
class="focus-visible:ring-ring flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none focus-visible:ring-1"
:aria-expanded="!collapse"
:aria-controls="bodyId"
:aria-label="
collapse ? t('rightSidePanel.expand') : t('rightSidePanel.collapse')
"
@click="collapse = !collapse"
>
<i
aria-hidden="true"
:class="
cn(
'icon-[lucide--chevron-up] size-4 text-muted-foreground transition-transform group-hover:text-base-foreground',
collapse && '-rotate-180'
)
"
/>
</button>
</div>
<TransitionCollapse>
<div v-if="!collapse" :id="bodyId">
<slot />
</div>
</TransitionCollapse>
</section>
</template>
<script setup lang="ts">
import { useId } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
const {
title,
count,
class: className
} = defineProps<{
title: string
count: number
class?: string
}>()
const collapse = defineModel<boolean>('collapse', { default: false })
const bodyId = useId()
const { t } = useI18n()
</script>

View File

@@ -1,31 +1,29 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
v-if="card.nodeId && !compact"
class="flex min-h-8 flex-wrap items-center gap-2"
class="flex flex-wrap items-center gap-2 py-2"
>
<span class="flex min-w-0 flex-1">
<button
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
type="button"
class="focus-visible:ring-ring m-0 max-w-full min-w-0 cursor-pointer appearance-none truncate rounded-sm border-0 bg-transparent p-0 text-left text-xs font-normal text-base-foreground outline-none focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="handleLocateNode"
>
{{ card.nodeTitle || card.title }}
</button>
<span
v-else-if="card.nodeTitle || card.title"
class="max-w-full min-w-0 truncate text-xs font-normal text-base-foreground"
>
{{ card.nodeTitle || card.title }}
</span>
<button
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
type="button"
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="handleLocateNode"
>
{{ card.nodeTitle || card.title }}
</button>
<span
v-else-if="card.nodeTitle || card.title"
class="flex-1 truncate text-sm font-medium text-muted-foreground"
>
{{ card.nodeTitle || card.title }}
</span>
<div class="flex shrink-0 items-center">
<Button
v-if="card.isSubgraphNode"
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
class="h-8 shrink-0 rounded-lg text-sm"
@click.stop="handleEnterSubgraph"
>
{{ t('rightSidePanel.enterSubgraph') }}
@@ -36,7 +34,7 @@
size="icon-sm"
:class="
cn(
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
runtimeDetailsExpanded &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
@@ -51,7 +49,7 @@
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"
>
@@ -61,29 +59,29 @@
</div>
<div
class="flex min-h-0 flex-1 flex-col space-y-2 divide-y divide-interface-stroke/20"
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
>
<div
v-for="(error, idx) in card.errors"
:key="idx"
class="flex min-h-0 flex-col gap-1"
class="flex min-h-0 flex-col gap-3"
>
<p
v-if="getInlineMessage(error)"
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-xs/relaxed wrap-break-word whitespace-pre-wrap"
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
>
{{ getInlineMessage(error) }}
</p>
<ul
v-if="getInlineItemLabel(error)"
class="m-0 list-disc space-y-1 pl-5 text-xs/relaxed text-muted-foreground marker:text-muted-foreground"
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
>
<li class="min-w-0 wrap-break-word">
<button
v-if="card.nodeId"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="handleLocateNode"
>
{{ getInlineItemLabel(error) }}
@@ -98,13 +96,13 @@
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
:class="
cn(
'overflow-y-auto rounded-lg bg-base-foreground/5 p-3',
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
'max-h-[6lh]'
)
"
>
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ getInlineDetails(error, idx) }}
</p>
@@ -117,61 +115,60 @@
role="region"
data-testid="runtime-error-panel"
:aria-label="t('rightSidePanel.errorLog')"
class="flex min-h-0 flex-col gap-1"
class="flex min-h-0 flex-col gap-3"
>
<div
v-if="getInlineDetails(error, idx)"
class="flex flex-col gap-3 rounded-lg bg-base-foreground/5 p-3"
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-1 py-1">
<span
class="text-xs font-semibold text-base-foreground uppercase"
>
{{ t('rightSidePanel.errorLog') }}
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="t('g.copy')"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div class="max-h-[15lh] overflow-y-auto">
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
>
{{ getInlineDetails(error, idx) }}
</p>
</div>
<div
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
>
<span
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
>
{{ t('rightSidePanel.errorLog') }}
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('g.copy')"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
<p
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ getInlineDetails(error, idx) }}
</p>
</div>
<div aria-hidden="true" class="h-px w-full bg-interface-stroke" />
<div class="flex items-center justify-between gap-2">
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
<div class="mx-3 flex items-center justify-between gap-2 py-2">
<Button
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
variant="textonly"
size="sm"
class="justify-start gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
@click="handleGetHelp"
>
<i class="icon-[lucide--external-link] size-4" />
<i class="icon-[lucide--external-link] size-3.5" />
{{ t('g.getHelpAction') }}
</Button>
<Button
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
variant="textonly"
size="sm"
class="justify-end gap-1 px-0 text-xs hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
<i class="icon-[lucide--github] size-4" />
<i class="icon-[lucide--github] size-3.5" />
{{ t('g.findOnGithub') }}
</Button>
</div>

View File

@@ -1,5 +1,5 @@
<template>
<div data-testid="missing-node-card" class="px-3">
<div data-testid="missing-node-card" class="px-4 pb-2">
<!-- Core node version warning (OSS only) -->
<div
v-if="!isCloud && hasMissingCoreNodes"
@@ -56,7 +56,7 @@
>
</template>
</i18n-t>
<div class="flex flex-col gap-1 overflow-hidden">
<div class="flex flex-col gap-1 overflow-hidden py-2">
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
@@ -75,7 +75,7 @@
variant="secondary"
size="sm"
:disabled="isRestarting"
class="mt-2 h-8 w-full min-w-0 rounded-md text-xs"
class="mt-2 h-8 w-full min-w-0 rounded-lg text-sm"
@click="applyChanges()"
>
<DotSpinner v-if="isRestarting" duration="1s" :size="12" />

View File

@@ -12,17 +12,17 @@
: t('rightSidePanel.missingNodePacks.expand')
"
:aria-expanded="expanded"
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
@click="toggleExpand"
>
<i
aria-hidden="true"
:class="
cn(
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90'
)
"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
<i
@@ -64,7 +64,7 @@
</button>
<span
v-else
class="min-w-0 truncate text-xs/relaxed font-normal"
class="min-w-0 truncate text-sm/relaxed font-normal"
:class="
isUnknownPack ? 'text-warning-background' : 'text-base-foreground'
"
@@ -80,7 +80,7 @@
v-if="showInfoButton && group.packId !== null"
variant="textonly"
size="icon-sm"
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
class="size-7 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingNodePacks.viewInManager')"
@click="emit('openManagerInfo', group.packId ?? '')"
>
@@ -89,7 +89,7 @@
<span
v-if="showNodeCount"
data-testid="missing-node-pack-count"
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
>
{{ group.nodeTypes.length }}
</span>
@@ -99,7 +99,7 @@
<Button
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
class="h-8 shrink-0 rounded-lg text-sm"
:disabled="isPackInstalled || isInstalling"
@click="handlePackInstallClick"
>
@@ -122,10 +122,10 @@
</div>
<div
v-else-if="showLoadingAction"
class="ml-auto flex h-6 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-sm bg-secondary-background px-2 py-1 text-xs opacity-60 select-none"
class="ml-auto flex h-8 shrink-0 cursor-not-allowed items-center justify-center overflow-hidden rounded-lg bg-secondary-background px-2 py-1 text-sm opacity-60 select-none"
>
<DotSpinner duration="1s" :size="12" class="mr-1.5 shrink-0" />
<span class="text-foreground min-w-0 truncate text-xs">
<span class="text-foreground min-w-0 truncate text-sm">
{{ t('g.loading') }}
</span>
</div>
@@ -133,7 +133,7 @@
<Button
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
class="h-8 shrink-0 rounded-lg text-sm"
@click="
openManager({
initialTab: ManagerTab.All,
@@ -150,7 +150,7 @@
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(primaryLocatableNodeType)"
>
@@ -163,7 +163,7 @@
v-if="showNodeTypeList"
:class="
cn(
'm-0 list-none p-0',
'm-0 list-none space-y-1 p-0',
(hasMultipleNodeTypes || isUnknownPack) && 'pl-5'
)
"
@@ -190,7 +190,7 @@
</button>
<span
v-else
class="text-xs/relaxed wrap-break-word text-muted-foreground"
class="text-sm/relaxed wrap-break-word text-muted-foreground"
>
{{ getLabel(nodeType) }}
</span>
@@ -199,7 +199,7 @@
v-if="isLocatableNodeType(nodeType)"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
@@ -241,7 +241,7 @@ const { t } = useI18n()
const expandedOverride = ref<boolean | null>(null)
const packTextButtonClass =
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word outline-none focus:outline-none rounded-sm focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset focus-visible:outline-none'
'm-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word outline-none focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none'
const { missingNodePacks, isLoading } = useMissingNodes()
const comfyManagerStore = useComfyManagerStore()

View File

@@ -78,10 +78,6 @@ describe('TabErrors.vue', () => {
rightSidePanel: {
noErrors: 'No errors',
noneSearchDesc: 'No results found',
errorsDetected: 'Error detected | Errors detected',
resolveBeforeRun: 'Resolve before running the workflow',
expand: 'Expand',
collapse: 'Collapse',
errorHelp: 'Error help',
errorLog: 'Error log',
findOnGithubTooltip: 'Search GitHub issues',
@@ -122,6 +118,9 @@ describe('TabErrors.vue', () => {
template:
'<input @input="$emit(\'update:modelValue\', $event.target.value)" />'
},
PropertiesAccordionItem: {
template: '<div><slot name="label" /><slot /></div>'
},
Button: {
template: '<button v-bind="$attrs"><slot /></button>'
}
@@ -212,13 +211,7 @@ describe('TabErrors.vue', () => {
})
expect(screen.getByText('Missing connection')).toBeInTheDocument()
expect(
within(screen.getByTestId('error-group-execution')).getByText('3')
).toBeInTheDocument()
expect(
within(screen.getByTestId('errors-summary-hero')).getByText('3')
).toBeInTheDocument()
expect(screen.getByText('Errors detected')).toBeInTheDocument()
expect(screen.getByText('(3)')).toBeInTheDocument()
expect(
screen.getAllByText(
'Required input slots have no connection feeding them.'
@@ -411,7 +404,7 @@ describe('TabErrors.vue', () => {
})
const missingModelStore = useMissingModelStore()
expect(screen.getByText('Missing Models')).toBeInTheDocument()
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
expect(
screen.queryByTestId('missing-model-actions')
).not.toBeInTheDocument()
@@ -421,40 +414,6 @@ describe('TabErrors.vue', () => {
expect(missingModelStore.refreshMissingModels).toHaveBeenCalled()
})
it('counts missing models per file when several share one directory', () => {
renderComponent({
missingModel: {
missingModelCandidates: [
{
nodeId: '1',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'model-a.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
},
{
nodeId: '2',
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
name: 'model-b.safetensors',
directory: 'checkpoints',
isMissing: true,
isAssetSupported: true
}
] satisfies MissingModelCandidate[]
}
})
expect(
within(screen.getByTestId('error-group-missing-model')).getByText('2')
).toBeInTheDocument()
expect(
within(screen.getByTestId('errors-summary-hero')).getByText('2')
).toBeInTheDocument()
})
it('renders missing model display message below the section title', () => {
const missingModel = {
nodeId: '1',
@@ -472,7 +431,7 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByText('Missing Models')).toBeInTheDocument()
expect(screen.getByText('Missing Models (1)')).toBeInTheDocument()
expect(
screen.getByText('Download a model, or open the node to replace it.')
).toBeInTheDocument()
@@ -494,7 +453,7 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByText('Missing Inputs')).toBeInTheDocument()
expect(screen.getByText('Missing Inputs (1)')).toBeInTheDocument()
expect(
screen.getByText('A required media input has no file selected.')
).toBeInTheDocument()
@@ -536,12 +495,6 @@ describe('TabErrors.vue', () => {
})
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
expect(
within(screen.getByTestId('error-group-missing-media')).getByText('2')
).toBeInTheDocument()
expect(
within(screen.getByTestId('errors-summary-hero')).getByText('2')
).toBeInTheDocument()
await user.click(
screen.getByRole('button', { name: 'Second Loader - image' })
@@ -573,7 +526,7 @@ describe('TabErrors.vue', () => {
}
})
expect(screen.getByText('Swap Nodes')).toBeInTheDocument()
expect(screen.getByText('Swap Nodes (1)')).toBeInTheDocument()
expect(
screen.getByText('Some nodes can be replaced with alternatives')
).toBeInTheDocument()

View File

@@ -11,62 +11,49 @@
/>
</div>
<div
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
aria-live="polite"
>
<div
v-if="filteredGroups.length === 0"
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
</div>
<div
v-else
class="overflow-hidden rounded-lg border border-secondary-background"
>
<!-- Errors summary hero -->
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<TransitionGroup tag="div" name="list-scale" class="relative">
<div
data-testid="errors-summary-hero"
class="flex items-center gap-2 bg-base-foreground/5 p-2"
v-if="filteredGroups.length === 0"
key="empty"
class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground"
>
<span
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
>
{{ totalErrorCount }}
</span>
<span
aria-hidden="true"
class="h-9 w-px shrink-0 bg-interface-stroke"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
<span class="text-xs/tight font-semibold text-base-foreground">
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
</span>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.resolveBeforeRun') }}
</span>
</div>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
</div>
<!-- Group by Class Type -->
<TransitionGroup tag="div" name="list-scale" class="relative">
<ErrorCardSection
v-for="group in filteredGroups"
:key="group.groupKey"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:title="group.displayTitle"
:count="getGroupCount(group)"
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
class="border-t border-secondary-background first:border-t-0"
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
>
<template #actions>
<PropertiesAccordionItem
v-for="group in filteredGroups"
:key="group.groupKey"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
class="border-b border-interface-stroke"
:size="getGroupSize(group)"
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
>
<template #label>
<div class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-2">
<i
class="icon-[lucide--octagon-alert] size-4 shrink-0 text-destructive-background-hover"
/>
<span class="truncate text-destructive-background-hover">
{{ group.displayTitle }}
</span>
<span
v-if="
group.type === 'execution' &&
getExecutionGroupCount(group) > 1
"
class="text-destructive-background-hover"
>
({{ getExecutionGroupCount(group) }})
</span>
</span>
<Button
v-if="
group.type === 'missing_node' &&
@@ -75,7 +62,7 @@
"
variant="secondary"
size="sm"
class="shrink-0"
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
:disabled="isInstallingAll"
@click.stop="installAll"
>
@@ -96,7 +83,7 @@
"
variant="secondary"
size="sm"
class="shrink-0"
class="mr-2 h-8 shrink-0 rounded-lg text-sm"
@click.stop="handleReplaceAll()"
>
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
@@ -109,7 +96,7 @@
data-testid="missing-model-header-refresh"
variant="muted-textonly"
size="icon"
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
class="mr-2 shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingModels.refresh')"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@@ -142,142 +129,140 @@
: ''
}}
</span>
</template>
<div
v-if="group.displayMessage"
data-testid="error-group-display-message"
class="px-3 py-1"
>
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
>
{{ group.displayMessage }}
</p>
</div>
</template>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
/>
<div
v-if="group.displayMessage"
data-testid="error-group-display-message"
class="px-4 pt-1 pb-3"
>
<p
class="m-0 text-sm/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ group.displayMessage }}
</p>
</div>
<!-- Swap Nodes -->
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
/>
<!-- Execution Errors -->
<div v-if="isExecutionItemListGroup(group)" class="px-3">
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="item in getExecutionItemList(group)"
:key="item.key"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-tooltip.top="{
value: item.displayDetails || undefined,
showDelay: 300
}"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="handleLocateNode(item.nodeId)"
>
{{ item.label }}
</button>
<Button
v-if="item.displayDetails"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
isExecutionItemDetailExpanded(item.key) &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="
t('rightSidePanel.infoFor', { item: item.label })
"
:aria-controls="getExecutionItemDetailId(item.key)"
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
@click.stop="toggleExecutionItemDetail(item.key)"
>
<i class="icon-[lucide--info] size-3.5" />
</Button>
</span>
<!-- Swap Nodes -->
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- Execution Errors -->
<div v-if="isExecutionItemListGroup(group)" class="px-4">
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="item in getExecutionItemList(group)"
:key="item.key"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-tooltip.top="{
value: item.displayDetails || undefined,
showDelay: 300
}"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="handleLocateNode(item.nodeId)"
>
{{ item.label }}
</button>
<Button
v-if="item.displayDetails"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:class="
cn(
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
isExecutionItemDetailExpanded(item.key) &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="
t('rightSidePanel.locateNodeFor', { item: item.label })
t('rightSidePanel.infoFor', { item: item.label })
"
@click.stop="handleLocateNode(item.nodeId)"
:aria-controls="getExecutionItemDetailId(item.key)"
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
@click.stop="toggleExecutionItemDetail(item.key)"
>
<i class="icon-[lucide--locate] size-4" />
<i class="icon-[lucide--info] size-3.5" />
</Button>
</div>
<TransitionCollapse>
<p
v-if="
item.displayDetails &&
isExecutionItemDetailExpanded(item.key)
"
:id="getExecutionItemDetailId(item.key)"
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ item.displayDetails }}
</p>
</TransitionCollapse>
</li>
</ul>
</div>
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Missing Models -->
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
@locate-model="handleLocateAssetNode"
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="
t('rightSidePanel.locateNodeFor', { item: item.label })
"
@click.stop="handleLocateNode(item.nodeId)"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<p
v-if="
item.displayDetails &&
isExecutionItemDetailExpanded(item.key)
"
:id="getExecutionItemDetailId(item.key)"
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ item.displayDetails }}
</p>
</TransitionCollapse>
</li>
</ul>
</div>
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Missing Media -->
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
@locate-node="handleLocateAssetNode"
/>
</ErrorCardSection>
</TransitionGroup>
</div>
<!-- Missing Models -->
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
@locate-model="handleLocateAssetNode"
/>
<!-- Missing Media -->
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
@locate-node="handleLocateAssetNode"
/>
</PropertiesAccordionItem>
</TransitionGroup>
</div>
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
<!-- Fixed Footer: Help Links -->
<div
class="min-w-0 shrink-0 border-t border-interface-stroke bg-interface-panel-surface p-4"
>
<div class="min-w-0 shrink-0 border-t border-interface-stroke p-4">
<i18n-t
keypath="rightSidePanel.errorHelp"
tag="p"
@@ -319,16 +304,15 @@ import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import ErrorCardSection from './ErrorCardSection.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
@@ -372,6 +356,16 @@ const searchQuery = ref('')
const expandedExecutionItemDetailKeys = ref(new Set<string>())
const isSearching = computed(() => searchQuery.value.trim() !== '')
const fullSizeGroupTypes = new Set([
'missing_node',
'swap_nodes',
'missing_model',
'missing_media'
])
function getGroupSize(group: ErrorGroup) {
return fullSizeGroupTypes.has(group.type) ? 'lg' : 'default'
}
function isExecutionItemListGroup(group: ErrorGroup) {
return (
group.type === 'execution' &&
@@ -458,28 +452,6 @@ const {
swapNodeGroups
} = useErrorGroups(searchQuery)
function getGroupCount(group: ErrorGroup): number {
switch (group.type) {
case 'execution':
return getExecutionGroupCount(group)
case 'missing_node':
return missingPackGroups.value.length
case 'swap_nodes':
return swapNodeGroups.value.length
case 'missing_model':
return missingModelGroups.value.reduce(
(total, modelGroup) => total + modelGroup.models.length,
0
)
case 'missing_media':
return countMissingMediaReferences(missingMediaGroups.value)
}
}
const totalErrorCount = computed(() =>
filteredGroups.value.reduce((sum, group) => sum + getGroupCount(group), 0)
)
const showMissingModelHeaderRefresh = computed(
() => !isCloud && missingModelGroups.value.length > 0
)

View File

@@ -334,7 +334,7 @@ describe('useErrorGroups', () => {
)
expect(missingGroup).toBeDefined()
expect(missingGroup?.groupKey).toBe('missing_node')
expect(missingGroup?.displayTitle).toBe('Missing Node Packs')
expect(missingGroup?.displayTitle).toBe('Missing Node Packs (1)')
expect(missingGroup?.displayMessage).toBe(
'Install missing packs to use this workflow.'
)
@@ -982,7 +982,7 @@ describe('useErrorGroups', () => {
)
expect(modelGroup).toBeDefined()
expect(modelGroup?.groupKey).toBe('missing_model')
expect(modelGroup?.displayTitle).toBe('Missing Models')
expect(modelGroup?.displayTitle).toBe('Missing Models (1)')
})
})
@@ -1098,7 +1098,7 @@ describe('useErrorGroups', () => {
const missingMediaGroup = groups.allErrorGroups.value.find(
(group) => group.type === 'missing_media'
)
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs')
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
})
})

View File

@@ -2,7 +2,6 @@ import { render } from '@testing-library/vue'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { nextTick, reactive, ref } from 'vue'
import type { Ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobState } from '@/types/queue'
@@ -20,24 +19,32 @@ type TestTask = {
workflowId?: string
}
const createTestI18n = () =>
createI18n({
legacy: false,
locale: 'en-US',
messages: {
'en-US': {
queue: {
jobList: {
undated: 'Undated'
}
},
g: {
emDash: '--',
untitled: 'Untitled'
}
}
const translations: Record<string, string> = {
'queue.jobList.undated': 'Undated',
'g.emDash': '--',
'g.untitled': 'Untitled'
}
let localeRef: Ref<string>
let tMock: ReturnType<typeof vi.fn>
const ensureLocaleMocks = () => {
if (!localeRef) {
localeRef = ref('en-US') as Ref<string>
}
if (!tMock) {
tMock = vi.fn((key: string) => translations[key] ?? key)
}
return { localeRef, tMock }
}
vi.mock('vue-i18n', () => ({
useI18n: () => {
ensureLocaleMocks()
return {
t: tMock,
locale: localeRef
}
})
}
}))
vi.mock('@/i18n', () => ({
st: vi.fn((key: string, fallback?: string) => `i18n(${key})-${fallback}`)
@@ -177,20 +184,13 @@ const createTask = (
const mountUseJobList = () => {
let composable: ReturnType<typeof useJobList>
const result = render(
{
template: '<div />',
setup() {
composable = useJobList()
return {}
}
},
{
global: {
plugins: [createTestI18n()]
}
const result = render({
template: '<div />',
setup() {
composable = useJobList()
return {}
}
)
})
return { ...result, composable: composable! }
}
@@ -215,6 +215,10 @@ const resetStores = () => {
totalPercent.value = 0
currentNodePercent.value = 0
ensureLocaleMocks()
localeRef.value = 'en-US'
tMock.mockClear()
if (isJobInitializingMock) {
vi.mocked(isJobInitializingMock).mockReset()
vi.mocked(isJobInitializingMock).mockReturnValue(false)
@@ -557,35 +561,6 @@ describe('useJobList', () => {
)
})
it('groups terminal jobs without an execution end timestamp by create time', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))
queueStoreMock.historyTasks = [
createTask({
jobId: 'failed-before-execution',
job: { priority: 1 },
mockState: 'failed',
createTime: Date.now()
}),
createTask({
jobId: 'completed-without-end-time',
job: { priority: 1 },
mockState: 'completed',
createTime: Date.now() - 1_000
})
]
const instance = initComposable()
await flush()
const groups = instance.groupedJobItems.value
expect(groups.map((g) => g.label)).toEqual(['Today'])
expect(groups[0].items.map((item) => item.id)).toEqual([
'failed-before-execution',
'completed-without-end-time'
])
})
it('groups job items by date label and sorts by total generation time when requested', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-01-10T12:00:00Z'))

View File

@@ -341,7 +341,7 @@ export function useJobList() {
for (const { task, state } of searchableTaskEntries.value) {
let ts: number | undefined
if (state === 'completed' || state === 'failed') {
ts = task.executionEndTimestamp ?? task.createTime
ts = task.executionEndTimestamp
} else {
ts = task.createTime
}

View File

@@ -1,94 +1,105 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCopy } from './useCopy'
import { describe, expect, it } from 'vitest'
const copyMocks = vi.hoisted(() => ({
copyHandler: undefined as ((event: ClipboardEvent) => unknown) | undefined,
canvas: {
selectedItems: new Set<object>([{}]),
copyToClipboard: vi.fn()
}
}))
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn(
(
_target: EventTarget,
event: string,
handler: (event: ClipboardEvent) => unknown
) => {
if (event === 'copy') copyMocks.copyHandler = handler
return vi.fn()
}
/**
* Encodes a UTF-8 string to base64 (same logic as useCopy.ts)
*/
function encodeClipboardData(data: string): string {
return btoa(
String.fromCharCode(...Array.from(new TextEncoder().encode(data)))
)
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: copyMocks.canvas
})
}))
vi.mock('@/workbench/eventHelpers', () => ({
shouldIgnoreCopyPaste: vi.fn(() => false)
}))
const multiChunkPayloadLength = 0x8000 * 6 + 123
function copySerializedData(serializedData: string): DataTransfer {
copyMocks.canvas.copyToClipboard.mockReturnValue(serializedData)
useCopy()
const dataTransfer = new DataTransfer()
const event = new ClipboardEvent('copy', {
clipboardData: dataTransfer
})
const copyHandler = copyMocks.copyHandler
expect(copyHandler).toBeDefined()
if (!copyHandler) throw new Error('Expected copy handler to be registered')
expect(() => copyHandler(event)).not.toThrow()
return dataTransfer
}
function readSerializedClipboardMetadata(dataTransfer: DataTransfer): string {
const match = dataTransfer
.getData('text/html')
.match(/data-metadata="([A-Za-z0-9+/=]+)"/)?.[1]
expect(match).toBeDefined()
if (!match) throw new Error('Expected clipboard metadata to be written')
const binaryString = atob(match)
const bytes = Uint8Array.from(binaryString, (char) => char.charCodeAt(0))
/**
* Decodes base64 to UTF-8 string (same logic as usePaste.ts)
*/
function decodeClipboardData(base64: string): string {
const binaryString = atob(base64)
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
describe('useCopy', () => {
beforeEach(() => {
copyMocks.copyHandler = undefined
copyMocks.canvas.copyToClipboard.mockReset()
describe('Clipboard UTF-8 base64 encoding/decoding', () => {
it('should handle ASCII-only strings', () => {
const original = '{"nodes":[{"id":1,"type":"LoadImage"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should write large serialized node data to clipboard metadata', () => {
const serializedData = JSON.stringify({
it('should handle Chinese characters in localized_name', () => {
const original =
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"图像"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle Japanese characters', () => {
const original = '{"localized_name":"画像を読み込む"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle Korean characters', () => {
const original = '{"localized_name":"이미지 불러오기"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle mixed ASCII and Unicode characters', () => {
const original =
'{"nodes":[{"id":1,"type":"LoadImage","localized_name":"加载图像","label":"Load Image 图片"}]}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle emoji characters', () => {
const original = '{"title":"Test Node 🎨🖼️"}'
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle empty string', () => {
const original = ''
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
})
it('should handle complex node data with multiple Unicode fields', () => {
const original = JSON.stringify({
nodes: [
{
id: 1,
type: 'Subgraph',
title: 'Large Subgraph',
localized_name: '이미지 그룹 图像 🎨',
payload: 'x'.repeat(multiChunkPayloadLength)
type: 'LoadImage',
localized_name: '图像',
inputs: [{ localized_name: '图片', name: 'image' }],
outputs: [{ localized_name: '输出', name: 'output' }]
}
],
groups: [{ title: '预处理组 🔧' }],
reroutes: [],
links: [],
subgraphs: []
links: []
})
const encoded = encodeClipboardData(original)
const decoded = decodeClipboardData(encoded)
expect(decoded).toBe(original)
expect(JSON.parse(decoded)).toEqual(JSON.parse(original))
})
const dataTransfer = copySerializedData(serializedData)
it('should produce valid base64 output', () => {
const original = '{"localized_name":"中文测试"}'
const encoded = encodeClipboardData(original)
// Base64 should only contain valid characters
expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/)
})
expect(readSerializedClipboardMetadata(dataTransfer)).toBe(serializedData)
it('should fail with plain btoa for non-Latin1 characters', () => {
const original = '{"localized_name":"图像"}'
// This demonstrates why we need TextEncoder - plain btoa fails
expect(() => btoa(original)).toThrow()
})
})

View File

@@ -7,29 +7,6 @@ const clipboardHTMLWrapper = [
'<meta charset="utf-8"><div><span data-metadata="',
'"></span></div><span style="white-space:pre-wrap;">Text</span>'
]
const clipboardByteChunkSize = 0x8000
function bytesToBinaryString(bytes: Uint8Array): string {
const chunks: string[] = []
for (
let offset = 0;
offset < bytes.length;
offset += clipboardByteChunkSize
) {
chunks.push(
String.fromCharCode(
...bytes.subarray(offset, offset + clipboardByteChunkSize)
)
)
}
return chunks.join('')
}
function encodeClipboardData(data: string): string {
return btoa(bytesToBinaryString(new TextEncoder().encode(data)))
}
/**
* Adds a handler on copy that serializes selected nodes to JSON
@@ -46,16 +23,17 @@ export const useCopy = () => {
const canvas = canvasStore.canvas
if (canvas?.selectedItems) {
const serializedData = canvas.copyToClipboard()
try {
const base64Data = encodeClipboardData(serializedData)
// clearData doesn't remove images from clipboard
e.clipboardData?.setData(
'text/html',
clipboardHTMLWrapper.join(base64Data)
// Use TextEncoder to handle Unicode characters properly
const base64Data = btoa(
String.fromCharCode(
...Array.from(new TextEncoder().encode(serializedData))
)
} catch (error) {
console.error(error)
}
)
// clearData doesn't remove images from clipboard
e.clipboardData?.setData(
'text/html',
clipboardHTMLWrapper.join(base64Data)
)
e.preventDefault()
e.stopImmediatePropagation()
return false

View File

@@ -119,23 +119,6 @@ describe('load3dLazy', () => {
expect(spec.upload_subfolder).toBe('3d')
})
it('injects mesh_upload spec flags into the model_file widget for Load3DAdvanced nodes', async () => {
const { hook } = await loadLazyExtensionFresh()
const nodeData = makeNodeDef('Load3DAdvanced', {
input: {
required: { model_file: ['STRING', {}] }
}
} as Partial<ComfyNodeDef>)
await hook({} as typeof LGraphNode, nodeData)
const spec = (
nodeData.input!.required!.model_file as [string, Record<string, unknown>]
)[1]
expect(spec.mesh_upload).toBe(true)
expect(spec.upload_subfolder).toBe('3d')
})
it('does not throw when a Load3D node has no model_file widget spec', async () => {
const { hook } = await loadLazyExtensionFresh()
const nodeData = makeNodeDef('Load3D', {

View File

@@ -61,12 +61,18 @@ useExtensionService().registerExtension({
if (isLoad3dNode(nodeData.name)) {
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
// Load3D's model_file as a mesh upload widget without hardcoding.
if (nodeData.name === 'Load3D' || nodeData.name === 'Load3DAdvanced') {
if (nodeData.name === 'Load3D') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = '3d'
}
} else if (nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = ''
}
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,

View File

@@ -1,101 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyExtension } from '@/types/comfy'
const { app } = vi.hoisted(() => ({
app: {
registerExtension: vi.fn(),
graph: undefined as unknown as LGraph
}
}))
vi.mock('@/scripts/app', () => ({ app }))
type BeforeRegisterNodeDef = NonNullable<
ComfyExtension['beforeRegisterNodeDef']
>
interface FilenamePrefixWidget {
name: string
value: unknown
serializeValue?: () => string
}
async function loadExtension(): Promise<ComfyExtension> {
vi.resetModules()
app.registerExtension.mockClear()
await import('./saveImageExtraOutput')
return app.registerExtension.mock.calls[0][0] as ComfyExtension
}
async function createNodeWithFilenamePrefix(
nodeName: string,
prefix: string
): Promise<FilenamePrefixWidget> {
const ext = await loadExtension()
const nodeType = {
prototype: {}
} as unknown as Parameters<BeforeRegisterNodeDef>[0]
const nodeData = { name: nodeName } as ComfyNodeDef
await ext.beforeRegisterNodeDef!(
nodeType,
nodeData,
{} as Parameters<BeforeRegisterNodeDef>[2]
)
const widget: FilenamePrefixWidget = {
name: 'filename_prefix',
value: prefix
}
const node = { widgets: [widget] }
const proto = nodeType.prototype as { onNodeCreated?: () => void }
proto.onNodeCreated!.call(node)
return widget
}
describe('Comfy.SaveImageExtraOutput', () => {
beforeEach(() => {
const graph = new LGraph()
graph.add({
properties: { 'Node name for S&R': 'Sampler' },
widgets: [{ name: 'seed', value: 12345 }]
} as unknown as LGraphNode)
app.graph = graph
})
it.each([
'SaveImage',
'SaveImageAdvanced',
'SaveSVGNode',
'SaveVideo',
'SaveAnimatedWEBP',
'SaveWEBM',
'SaveAudio',
'SaveAudioMP3',
'SaveAudioOpus',
'SaveAudioAdvanced',
'SaveGLB',
'SaveAnimatedPNG',
'CLIPSave',
'VAESave',
'ModelSave',
'LoraSave',
'SaveLatent'
])(
'resolves text replacements in the filename_prefix of %s on serialize',
async (nodeName) => {
const widget = await createNodeWithFilenamePrefix(
nodeName,
'ComfyUI_%Sampler.seed%'
)
expect(widget.serializeValue!()).toBe('ComfyUI_12345')
}
)
})

View File

@@ -6,15 +6,10 @@ import { app } from '../../scripts/app'
const saveNodeTypes = new Set([
'SaveImage',
'SaveImageAdvanced',
'SaveSVGNode',
'SaveVideo',
'SaveAnimatedWEBP',
'SaveWEBM',
'SaveAudio',
'SaveAudioMP3',
'SaveAudioOpus',
'SaveAudioAdvanced',
'SaveGLB',
'SaveAnimatedPNG',
'CLIPSave',

View File

@@ -8931,20 +8931,71 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
/**
* Collect all nodes that are children of groups in the selection
*/
private collectNodesInGroups(items: Set<Positionable>): Set<LGraphNode> {
const nodesInGroups = new Set<LGraphNode>()
for (const item of items) {
if (item instanceof LGraphGroup) {
for (const child of item._children) {
if (child instanceof LGraphNode) {
nodesInGroups.add(child)
}
}
}
}
return nodesInGroups
}
/**
* Move group children (both nodes and non-nodes)
*/
private moveGroupChildren(
group: LGraphGroup,
deltaX: number,
deltaY: number,
nodesToMove: Array<{ node: LGraphNode; newPos: { x: number; y: number } }>
): void {
for (const child of group._children) {
if (child instanceof LGraphNode) {
const node = child as LGraphNode
nodesToMove.push({
node,
newPos: this.calculateNewPosition(node, deltaX, deltaY)
})
} else if (!(child instanceof LGraphGroup)) {
// Non-node, non-group children (reroutes, etc.)
// Skip groups here - they're already in allItems and will be
// processed in the main loop of moveChildNodesInGroupVueMode
child.move(deltaX, deltaY, true)
}
}
}
moveChildNodesInGroupVueMode(
allItems: Set<Positionable>,
deltaX: number,
deltaY: number
) {
const nodesInMovingGroups = this.collectNodesInGroups(allItems)
const nodesToMove: NewNodePosition[] = []
// First, collect all the moves we need to make
for (const item of allItems) {
if (item instanceof LGraphNode) {
const isNode = item instanceof LGraphNode
if (isNode) {
const node = item as LGraphNode
if (nodesInMovingGroups.has(node)) {
continue
}
nodesToMove.push({
node: item,
newPos: this.calculateNewPosition(item, deltaX, deltaY)
node,
newPos: this.calculateNewPosition(node, deltaX, deltaY)
})
} else if (item instanceof LGraphGroup) {
item.move(deltaX, deltaY, true)
this.moveGroupChildren(item, deltaX, deltaY, nodesToMove)
} else {
// Other items (reroutes, etc.)
item.move(deltaX, deltaY, true)

View File

@@ -3627,10 +3627,6 @@
"hideAdvancedShort": "Hide advanced",
"errors": "Errors",
"noErrors": "No errors",
"errorsDetected": "Error detected | Errors detected",
"resolveBeforeRun": "Resolve before running the workflow",
"expand": "Expand",
"collapse": "Collapse",
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
"errorLog": "Error log",
"findOnGithubTooltip": "Search GitHub issues for related problems",

View File

@@ -18,10 +18,7 @@ import { getOutputAssetMetadata } from '../schemas/assetMetadataSchema'
import { useAssetsStore } from '@/stores/assetsStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import {
getAssetDisplayName,
getAssetStoredFilename
} from '../utils/assetMetadataUtils'
import { getAssetDisplayName } from '../utils/assetMetadataUtils'
import { getAssetType } from '../utils/assetTypeUtil'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { clearDeletedAssetWidgetValues } from '../utils/clearDeletedAssetWidgetValues'
@@ -304,7 +301,10 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
const assetType = getAssetType(targetAsset, 'input')
const filename = getAssetStoredFilename(targetAsset)
// In Cloud mode, use the content hash (the actual stored filename).
// In OSS mode, use the original name.
const cloudHash = targetAsset.hash
const filename = isCloud && cloudHash ? cloudHash : targetAsset.name
// Create annotated path for the asset
const annotated = createAnnotatedPath(
@@ -445,7 +445,10 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(asset.user_metadata)
const assetType = getAssetType(asset, 'input')
const filename = getAssetStoredFilename(asset)
// In Cloud mode, use the content hash (the actual stored filename).
// In OSS mode, use the original name.
const cloudHash = asset.hash
const filename = isCloud && cloudHash ? cloudHash : asset.name
const annotated = createAnnotatedPath(
{

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
@@ -12,23 +12,11 @@ import {
getAssetFilename,
getAssetModelType,
getAssetSourceUrl,
getAssetStoredFilename,
getAssetTriggerPhrases,
getAssetUserDescription,
getSourceName
} from '@/platform/assets/utils/assetMetadataUtils'
const { isCloudRef } = vi.hoisted(() => ({
isCloudRef: { value: true }
}))
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
get isCloud() {
return isCloudRef.value
}
}))
describe('assetMetadataUtils', () => {
const mockAsset: AssetItem = {
id: 'test-id',
@@ -307,28 +295,6 @@ describe('assetMetadataUtils', () => {
})
})
describe('getAssetStoredFilename', () => {
afterEach(() => {
isCloudRef.value = true
})
it('returns the content hash on cloud when present', () => {
isCloudRef.value = true
expect(getAssetStoredFilename(mockAsset)).toBe('hash123')
})
it('falls back to name on cloud when no hash is present', () => {
isCloudRef.value = true
const asset = { ...mockAsset, hash: undefined }
expect(getAssetStoredFilename(asset)).toBe('test-model')
})
it('returns name on OSS regardless of hash', () => {
isCloudRef.value = false
expect(getAssetStoredFilename(mockAsset)).toBe('test-model')
})
})
describe('getAssetFilename', () => {
it('returns user_metadata.filename when present', () => {
const asset = {

View File

@@ -1,5 +1,4 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { isCivitaiUrl } from '@/utils/formatUtil'
/**
@@ -172,22 +171,6 @@ export function getAssetFilename(asset: AssetItem): string {
return getStringProperty(asset, 'filename') ?? asset.name
}
/**
* Resolves the filename that addresses an asset's *bytes* in storage — use
* this to build the path a backend resolves to a real file (the
* `createAnnotatedPath` input behind `/view` requests and widget values),
* never to show the user. Cloud is content-addressed, so it returns the
* content hash (`hash`); OSS is filesystem-backed, so it returns `name`.
*
* For a human-readable label use {@link getAssetDisplayFilename}; for a
* serialized identifier (matching, validation) use {@link getAssetFilename}.
*
* TODO(BE-933/934): collapse to `asset.file_path ?? asset.name`.
*/
export function getAssetStoredFilename(asset: AssetItem): string {
return isCloud && asset.hash ? asset.hash : asset.name
}
/**
* Gets the human-readable filename to render in UI surfaces.
* Fallback chain: user_metadata.filename → metadata.filename →

View File

@@ -143,7 +143,7 @@ const { t } = useI18n()
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span class="flex shrink-0 items-baseline gap-1.5 whitespace-nowrap">
<span
class="text-[2rem]/none font-semibold text-base-foreground"
class="text-[2rem] leading-none font-semibold text-base-foreground"
data-testid="credit-slider-price"
>
{{ formatUsd(displayMonthly) }}

View File

@@ -1394,7 +1394,7 @@ describe('errorMessageResolver', () => {
})
).toEqual({
catalogId: 'missing_node',
displayTitle: 'Missing Node Packs',
displayTitle: 'Missing Node Packs (1)',
displayMessage: 'Install missing packs to use this workflow.',
toastTitle: 'Missing node: FooNode',
toastMessage:
@@ -1410,7 +1410,7 @@ describe('errorMessageResolver', () => {
})
).toEqual({
catalogId: 'missing_node',
displayTitle: 'Unsupported Node Packs',
displayTitle: 'Unsupported Node Packs (1)',
displayMessage:
"Required custom nodes aren't supported on Cloud. Replace them with supported nodes.",
toastTitle: "FooNode isn't available on Cloud",
@@ -1471,7 +1471,7 @@ describe('errorMessageResolver', () => {
})
).toEqual({
catalogId: 'swap_nodes',
displayTitle: 'Swap Nodes',
displayTitle: 'Swap Nodes (1)',
displayMessage: 'Some nodes can be replaced with alternatives',
toastTitle: 'OldNode can be replaced',
toastMessage: 'Replace it with NewNode from the error panel.'
@@ -1520,7 +1520,7 @@ describe('errorMessageResolver', () => {
})
).toEqual({
catalogId: 'missing_model',
displayTitle: 'Missing Models',
displayTitle: 'Missing Models (1)',
displayMessage: 'Download a model, or open the node to replace it.',
toastTitle: 'sdxl.safetensors is missing',
toastMessage: 'Checkpoint Loader Simple is missing a required model file.'
@@ -1535,7 +1535,7 @@ describe('errorMessageResolver', () => {
})
).toEqual({
catalogId: 'missing_model',
displayTitle: 'Missing Models',
displayTitle: 'Missing Models (1)',
displayMessage: 'Import a model, or open the node to replace it.',
toastTitle: "sdxl.safetensors isn't available on Cloud",
toastMessage: "This model isn't supported. Choose a different one."
@@ -1573,7 +1573,7 @@ describe('errorMessageResolver', () => {
})
).toEqual({
catalogId: 'missing_media',
displayTitle: 'Missing Inputs',
displayTitle: 'Missing Inputs (1)',
displayMessage: 'A required media input has no file selected.',
toastTitle: 'Media input missing',
toastMessage: 'Load Image is missing a required media file.'
@@ -1707,7 +1707,7 @@ describe('errorMessageResolver', () => {
isCloud: false
})
).toMatchObject({
displayTitle: 'Missing Inputs',
displayTitle: 'Missing Inputs (2)',
toastTitle: 'Missing media inputs',
toastMessage:
'Please select the missing media inputs before running this workflow.'

View File

@@ -6,6 +6,10 @@ import { normalizeNodeName, translateCatalogMessage } from './catalogI18n'
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import { st } from '@/i18n'
function formatCountTitle(title: string, count: number): string {
return `${title} (${count})`
}
function formatNodeTypeName(nodeType: string): string | null {
const trimmed = nodeType.trim()
if (!trimmed) return null
@@ -340,12 +344,15 @@ export function resolveMissingErrorMessage(
case 'missing_node':
return {
catalogId: 'missing_node',
displayTitle: source.isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
displayTitle: formatCountTitle(
source.isCloud
? st(
'rightSidePanel.missingNodePacks.unsupportedTitle',
'Unsupported Node Packs'
)
: st('rightSidePanel.missingNodePacks.title', 'Missing Node Packs'),
source.count
),
displayMessage: resolveMissingNodeDisplayMessage(source),
toastTitle: resolveMissingNodeToastTitle(source),
toastMessage: resolveMissingNodeToastMessage(source)
@@ -353,7 +360,10 @@ export function resolveMissingErrorMessage(
case 'swap_nodes':
return {
catalogId: 'swap_nodes',
displayTitle: st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
displayTitle: formatCountTitle(
st('nodeReplacement.swapNodesTitle', 'Swap Nodes'),
source.count
),
displayMessage: resolveSwapNodeDisplayMessage(),
toastTitle: resolveSwapNodeToastTitle(source),
toastMessage: resolveSwapNodeToastMessage(source)
@@ -361,9 +371,12 @@ export function resolveMissingErrorMessage(
case 'missing_model':
return {
catalogId: 'missing_model',
displayTitle: st(
'rightSidePanel.missingModels.missingModelsTitle',
'Missing Models'
displayTitle: formatCountTitle(
st(
'rightSidePanel.missingModels.missingModelsTitle',
'Missing Models'
),
source.count
),
displayMessage: resolveMissingModelDisplayMessage(source),
toastTitle: resolveMissingModelToastTitle(source),
@@ -372,9 +385,9 @@ export function resolveMissingErrorMessage(
case 'missing_media':
return {
catalogId: 'missing_media',
displayTitle: st(
'rightSidePanel.missingMedia.missingMediaTitle',
'Missing Inputs'
displayTitle: formatCountTitle(
st('rightSidePanel.missingMedia.missingMediaTitle', 'Missing Inputs'),
source.count
),
displayMessage: resolveMissingMediaDisplayMessage(),
toastTitle: resolveMissingMediaToastTitle(source),

View File

@@ -1,5 +1,5 @@
<template>
<div class="px-3">
<div class="px-4 pb-2">
<TransitionGroup
tag="ul"
name="list-scale"
@@ -15,7 +15,7 @@
<span class="flex min-w-0 flex-1">
<button
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
@click="emit('locateNode', item.nodeId)"
>
{{ item.displayItemLabel }}
@@ -25,7 +25,7 @@
data-testid="missing-media-locate-button"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="
t('rightSidePanel.locateNodeFor', {
item: item.displayItemLabel

View File

@@ -1,9 +1,9 @@
<template>
<div class="px-3">
<div class="px-4 pb-2">
<div
v-if="importableModelRows.length > 0"
data-testid="missing-model-importable-rows"
class="flex flex-col gap-1 overflow-hidden"
class="flex flex-col gap-1 overflow-hidden py-2"
>
<MissingModelRow
v-for="row in importableModelRows"
@@ -19,7 +19,7 @@
<div
v-if="unsupportedModelRows.length > 0"
data-testid="missing-model-import-not-supported-section"
class="flex flex-col gap-1 border-t border-secondary-background pt-3"
class="flex flex-col gap-1 border-t border-interface-stroke pt-3"
>
<div class="mb-1">
<p class="m-0 text-sm font-semibold text-warning-background">
@@ -49,7 +49,7 @@
data-testid="missing-model-download-all"
variant="secondary"
size="sm"
class="h-8 min-w-0 flex-1 rounded-md text-xs"
class="h-8 min-w-0 flex-1 rounded-lg text-sm"
@click="downloadAllModels"
>
<i aria-hidden="true" class="icon-[lucide--download] size-4 shrink-0" />

View File

@@ -12,27 +12,27 @@
: t('rightSidePanel.missingModels.expandNodes')
"
:aria-expanded="expanded"
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
@click="handleToggleExpand"
>
<i
aria-hidden="true"
:class="
cn(
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90'
)
"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
<span class="flex min-w-0 flex-1 flex-col gap-0">
<span class="flex min-w-0 items-center gap-1 text-xs/tight">
<span class="block min-w-0 text-sm/tight">
<button
v-if="hasModelLabelControl"
ref="modelLabelControl"
type="button"
class="focus-visible:ring-ring m-0 min-w-0 cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
:title="displayModelName"
@click="handleModelLabelClick"
>
@@ -40,7 +40,7 @@
</button>
<span
v-else
class="min-w-0 font-normal wrap-break-word text-base-foreground"
class="font-normal wrap-break-word text-base-foreground"
:title="displayModelName"
>
{{ displayModelName }}
@@ -48,14 +48,14 @@
<span
v-if="hasMultipleReferences"
data-testid="missing-model-reference-count"
class="inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
class="ml-2 inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected align-middle text-xs font-bold text-muted-foreground"
>
{{ model.referencingNodes.length }}
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-6 shrink-0 text-muted-foreground hover:bg-transparent hover:text-base-foreground focus-visible:ring-inset"
class="ml-2 inline-flex size-7 shrink-0 align-middle text-muted-foreground hover:bg-transparent hover:text-base-foreground"
:aria-label="linkLabel"
:title="linkLabel"
@click="copyModelLink"
@@ -82,7 +82,7 @@
data-testid="missing-model-import"
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
class="h-8 shrink-0 rounded-lg text-sm"
@click="showUploadDialog"
>
{{ t('g.import') }}
@@ -123,7 +123,7 @@
data-testid="missing-model-download"
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
class="h-8 shrink-0 rounded-lg text-sm"
:aria-label="`${t('g.download')} ${model.name}`"
@click="handleDownload"
>
@@ -137,7 +137,7 @@
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="handleLocatePrimary"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
@@ -149,7 +149,7 @@
v-if="showReferenceList"
:class="
cn(
'm-0 list-none p-0',
'm-0 list-none space-y-0.5 p-0',
(hasMultipleReferences || isUnknownCategory) && 'pl-5'
)
"
@@ -159,10 +159,10 @@
:key="`${String(ref.nodeId)}::${ref.widgetName}`"
class="min-w-0"
>
<div class="flex min-h-8 min-w-0 items-center gap-2">
<div class="flex min-h-6 min-w-0 items-center gap-2">
<button
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/tight font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/tight font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="emit('locateModel', String(ref.nodeId))"
>
{{
@@ -174,7 +174,7 @@
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingModels.locateNode')"
class="ml-auto size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
class="ml-auto size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateModel', String(ref.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />

View File

@@ -3,26 +3,21 @@
<div class="flex min-h-8 w-full items-center gap-1">
<Button
v-if="hasMultipleNodeTypes"
data-testid="swap-node-group-expand"
variant="textonly"
size="unset"
:aria-label="
expanded
? t('rightSidePanel.missingNodePacks.collapse', 'Collapse')
: t('rightSidePanel.missingNodePacks.expand', 'Expand')
:class="
cn(
'h-8 w-4 shrink-0 p-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-90'
)
"
:aria-expanded="expanded"
class="h-8 w-4 shrink-0 p-0 hover:bg-transparent focus-visible:ring-inset"
aria-hidden="true"
tabindex="-1"
@click="toggleExpand"
>
<i
aria-hidden="true"
:class="
cn(
'icon-[lucide--chevron-right] size-4 text-muted-foreground transition-transform duration-200',
expanded && 'rotate-90'
)
"
class="icon-[lucide--chevron-right] size-4 text-muted-foreground"
/>
</Button>
@@ -32,7 +27,7 @@
<button
v-if="hasMultipleNodeTypes"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
:title="group.type"
:aria-label="titleToggleAriaLabel"
:aria-expanded="expanded"
@@ -43,7 +38,7 @@
<button
v-else-if="primaryLocatableNodeType"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-base-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
:title="group.type"
@click="handleLocateNode(primaryLocatableNodeType)"
>
@@ -51,7 +46,7 @@
</button>
<span
v-else
class="min-w-0 truncate text-xs/relaxed font-normal text-base-foreground"
class="min-w-0 truncate text-sm/relaxed font-normal text-base-foreground"
:title="group.type"
>
{{ group.type }}
@@ -60,7 +55,7 @@
v-if="hasMultipleNodeTypes"
data-testid="swap-node-group-count"
role="img"
class="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-secondary-background-hover px-1 text-2xs font-semibold text-base-foreground"
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-secondary-background-selected text-xs font-bold text-muted-foreground"
:aria-label="t('g.nodesCount', group.nodeTypes.length)"
>
{{ group.nodeTypes.length }}
@@ -85,7 +80,7 @@
<Button
variant="secondary"
size="sm"
class="shrink-0 focus-visible:ring-inset"
class="h-8 shrink-0 rounded-lg text-sm"
@click="handleReplaceNode"
>
<i
@@ -101,7 +96,7 @@
v-if="primaryLocatableNodeType"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="locateNodeLabel"
@click="handleLocateNode(primaryLocatableNodeType)"
>
@@ -121,14 +116,14 @@
<button
v-if="isLocatableNodeType(nodeType)"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-1 focus-visible:outline-none"
@click="handleLocateNode(nodeType)"
>
{{ getLabel(nodeType) }}
</button>
<span
v-else
class="text-xs/relaxed wrap-break-word text-muted-foreground"
class="text-sm/relaxed wrap-break-word text-muted-foreground"
>
{{ getLabel(nodeType) }}
</span>
@@ -137,7 +132,7 @@
v-if="isLocatableNodeType(nodeType)"
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="locateNodeLabel"
@click="handleLocateNode(nodeType)"
>

View File

@@ -1,5 +1,5 @@
<template>
<div class="px-3">
<div class="mt-2 px-4 pb-2">
<SwapNodeGroupRow
v-for="group in swapNodeGroups"
:key="group.type"

View File

@@ -355,13 +355,8 @@ describe('useNodeReplacement', () => {
{ name: 'largest_size', link: null }
],
[{ name: 'IMAGE', links: null }],
[
{ name: 'largest_size', value: 0 },
{ name: 'face_point_size', value: 1 }
]
[{ name: 'largest_size', value: 0 }]
)
const setNodeId = vi.fn()
Object.assign(newNode.widgets![1], { setNodeId })
vi.mocked(LiteGraph.createNode).mockReturnValue(newNode)
const { replaceNodesInPlace } = useNodeReplacement()
@@ -379,8 +374,8 @@ describe('useNodeReplacement', () => {
})
])
// Widget value should be transferred: old "longer_edge" (idx 0, value 512) → new "largest_size"
expect(newNode.widgets![0].value).toBe(512)
expect(setNodeId).toHaveBeenCalledWith(1)
})
it('should skip replacement when new node type is not registered', () => {

View File

@@ -2,7 +2,6 @@ import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { ISerialisedNode } from '@/lib/litegraph/src/types/serialisation'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
import { t } from '@/i18n'
import type { NodeReplacement } from '@/platform/nodeReplacement/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -171,9 +170,6 @@ function replaceWithMapping(
nodeGraph._nodes[idx] = newNode
newNode.graph = nodeGraph
nodeGraph._nodes_by_id[newNode.id] = newNode
for (const widget of newNode.widgets ?? []) {
if (isNodeBindable(widget)) widget.setNodeId(newNode.id)
}
const serialized = node.last_serialization ?? node.serialize()

View File

@@ -82,11 +82,6 @@ export type RemoteConfig = {
posthog_project_token?: string
posthog_api_host?: string
posthog_config?: Partial<PostHogConfig>
customer_io?: {
write_key?: string
site_id?: string
user_id?: string
}
subscription_required?: boolean
server_health_alert?: ServerHealthAlert
max_upload_size?: number

View File

@@ -1,5 +1,5 @@
<template>
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
<BaseModalLayout content-title="" data-testid="settings-dialog" size="sm">
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--settings]" />
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>

View File

@@ -53,16 +53,13 @@ describe('useSettingsDialog', () => {
isCloudRef.value = false
})
it("show() opens the Reka renderer with size 'full' and 1280px content sizing", () => {
it("show() opens the Reka renderer with size 'full' and 960px content sizing", () => {
useSettingsDialog().show()
const [args] = showDialog.mock.calls[0]
expect(args.key).toBe('global-settings')
expect(args.dialogComponentProps.renderer).toBe('reka')
expect(args.dialogComponentProps.size).toBe('full')
expect(args.dialogComponentProps.contentClass).toContain('max-w-[1280px]')
expect(args.dialogComponentProps.contentClass).not.toContain(
'max-w-[960px]'
)
expect(args.dialogComponentProps.contentClass).toContain('max-w-[960px]')
expect(args.dialogComponentProps.contentClass).toContain('h-[80vh]')
})

View File

@@ -8,9 +8,8 @@ import type { SettingPanelType } from '@/platform/settings/types'
const DIALOG_KEY = 'global-settings'
// The redesigned Settings dialog is 1280px wide (DES 3253-16079).
const SETTINGS_CONTENT_CLASS =
'w-[90vw] max-w-[1280px] sm:max-w-[1280px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
'w-[90vw] max-w-[960px] sm:max-w-[960px] h-[80vh] max-h-none rounded-2xl overflow-hidden'
export function useSettingsDialog() {
const dialogService = useDialogService()

View File

@@ -26,16 +26,14 @@ export async function initTelemetry(): Promise<void> {
{ GtmTelemetryProvider },
{ ImpactTelemetryProvider },
{ PostHogTelemetryProvider },
{ ClickHouseTelemetryProvider },
{ CustomerIoTelemetryProvider }
{ ClickHouseTelemetryProvider }
] = await Promise.all([
import('./TelemetryRegistry'),
import('./providers/cloud/MixpanelTelemetryProvider'),
import('./providers/cloud/GtmTelemetryProvider'),
import('./providers/cloud/ImpactTelemetryProvider'),
import('./providers/cloud/PostHogTelemetryProvider'),
import('./providers/cloud/ClickHouseTelemetryProvider'),
import('./providers/cloud/CustomerIoTelemetryProvider')
import('./providers/cloud/ClickHouseTelemetryProvider')
])
const registry = new TelemetryRegistry()
@@ -44,7 +42,6 @@ export async function initTelemetry(): Promise<void> {
registry.registerProvider(new ImpactTelemetryProvider())
registry.registerProvider(new PostHogTelemetryProvider())
registry.registerProvider(new ClickHouseTelemetryProvider())
registry.registerProvider(new CustomerIoTelemetryProvider())
setTelemetryRegistry(registry)
})()

View File

@@ -1,264 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const hoisted = vi.hoisted(() => {
const analytics = {
identify: vi.fn(),
track: vi.fn(),
reset: vi.fn(),
register: vi.fn().mockResolvedValue(undefined)
}
let resolvedCb: ((user: { id: string }) => void) | undefined
let logoutCb: (() => void) | undefined
return {
analytics,
load: vi.fn(() => analytics),
inAppPlugin: vi.fn(() => ({ name: 'Customer.io In-App Plugin' })),
onUserResolved: vi.fn((cb: (user: { id: string }) => void) => {
resolvedCb = cb
}),
onUserLogout: vi.fn((cb: () => void) => {
logoutCb = cb
}),
resolveUser: (id: string) => resolvedCb?.({ id }),
logoutUser: () => logoutCb?.()
}
})
vi.mock('@customerio/cdp-analytics-browser', () => ({
AnalyticsBrowser: { load: hoisted.load },
InAppPlugin: hoisted.inAppPlugin
}))
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserResolved: hoisted.onUserResolved,
onUserLogout: hoisted.onUserLogout
})
}))
import {
CustomerIoTelemetryProvider,
EVENT_SOURCE
} from './CustomerIoTelemetryProvider'
const WRITE_KEY = 'cdp_test_write_key'
const SITE_ID = 'f87746f8c188c8ddcf41'
const SOURCE = { event_source: EVENT_SOURCE }
function createProvider(
config: Partial<typeof window.__CONFIG__> = {
customer_io: { write_key: WRITE_KEY, site_id: SITE_ID }
}
): CustomerIoTelemetryProvider {
window.__CONFIG__ = config as typeof window.__CONFIG__
return new CustomerIoTelemetryProvider()
}
describe('CustomerIoTelemetryProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
hoisted.load.mockReturnValue(hoisted.analytics)
hoisted.analytics.register.mockResolvedValue(undefined)
window.__CONFIG__ = {} as typeof window.__CONFIG__
})
it('loads the client and registers the in-app plugin with the site id', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.load).toHaveBeenCalledWith({ writeKey: WRITE_KEY })
expect(hoisted.inAppPlugin).toHaveBeenCalledWith(
expect.objectContaining({ siteId: SITE_ID })
)
expect(hoisted.analytics.register).toHaveBeenCalled()
})
it('does not initialize without a write key', async () => {
const provider = createProvider({ customer_io: { site_id: SITE_ID } })
await vi.dynamicImportSettled()
provider.trackWorkflowExecution()
expect(hoisted.load).not.toHaveBeenCalled()
expect(hoisted.analytics.track).not.toHaveBeenCalled()
})
it('does not initialize without a site id', async () => {
createProvider({ customer_io: { write_key: WRITE_KEY } })
await vi.dynamicImportSettled()
expect(hoisted.load).not.toHaveBeenCalled()
})
it('identifies the person by uid only on auth resolve', async () => {
createProvider()
await vi.dynamicImportSettled()
hoisted.resolveUser('test-uid-7f3a9c')
expect(hoisted.analytics.identify).toHaveBeenCalledWith('test-uid-7f3a9c')
})
it('identifies with the configured user_id override without waiting for auth', async () => {
createProvider({
customer_io: {
write_key: WRITE_KEY,
site_id: SITE_ID,
user_id: 'forced-uid'
}
})
await vi.dynamicImportSettled()
expect(hoisted.analytics.identify).toHaveBeenCalledWith('forced-uid')
expect(hoisted.onUserResolved).not.toHaveBeenCalled()
})
it('identifies before flushing events buffered before the SDK loads', async () => {
const provider = createProvider({
customer_io: {
write_key: WRITE_KEY,
site_id: SITE_ID,
user_id: 'forced-uid'
}
})
provider.trackWorkflowExecution()
await vi.dynamicImportSettled()
const identifyOrder = hoisted.analytics.identify.mock.invocationCallOrder[0]
const trackOrder = hoisted.analytics.track.mock.invocationCallOrder[0]
expect(identifyOrder).toBeLessThan(trackOrder)
})
it('resets on logout', async () => {
createProvider()
await vi.dynamicImportSettled()
hoisted.logoutUser()
expect(hoisted.analytics.reset).toHaveBeenCalledOnce()
})
const DIRECT_EVENTS: Array<{
event: string
invoke: (p: CustomerIoTelemetryProvider) => void
expected: Record<string, unknown>
}> = [
{
event: 'app:user_auth_completed',
invoke: (p) => p.trackAuth({ method: 'google', is_new_user: true }),
expected: { ...SOURCE, method: 'google', is_new_user: true }
},
{
event: 'app:subscription_required_modal_opened',
invoke: (p) =>
p.trackSubscription('modal_opened', { current_tier: 'pro' }),
expected: { ...SOURCE, current_tier: 'pro' }
},
{
event: 'app:subscribe_now_button_clicked',
invoke: (p) => p.trackSubscription('subscribe_clicked'),
expected: SOURCE
},
{
event: 'app:add_api_credit_button_clicked',
invoke: (p) => p.trackAddApiCreditButtonClicked(),
expected: SOURCE
},
{
event: 'execution_start',
invoke: (p) => p.trackWorkflowExecution(),
expected: SOURCE
},
{
event: 'execution_success',
invoke: (p) => p.trackExecutionSuccess({ jobId: 'job-abc' }),
expected: { ...SOURCE, jobId: 'job-abc' }
},
{
event: 'app:template_workflow_opened',
invoke: (p) => p.trackTemplate({ workflow_name: 'flux-dev' }),
expected: { ...SOURCE, workflow_name: 'flux-dev' }
},
{
event: 'app:template_library_opened',
invoke: (p) => p.trackTemplateLibraryOpened({ source: 'sidebar' }),
expected: { ...SOURCE, source: 'sidebar' }
},
{
event: 'app:share_flow',
invoke: (p) =>
p.trackShareFlow({
step: 'dialog_opened',
view_mode: 'graph',
is_app_mode: false
}),
expected: {
...SOURCE,
step: 'dialog_opened',
view_mode: 'graph',
is_app_mode: false
}
}
]
it.for(DIRECT_EVENTS)(
'sends $event with its metadata merged under the event_source tag',
async ({ event, invoke, expected }) => {
const provider = createProvider()
await vi.dynamicImportSettled()
invoke(provider)
expect(hoisted.analytics.track).toHaveBeenCalledWith(event, expected)
}
)
it('flushes events buffered before load once, in order', async () => {
const provider = createProvider()
provider.trackWorkflowExecution()
provider.trackAddApiCreditButtonClicked()
expect(hoisted.analytics.track).not.toHaveBeenCalled()
await vi.dynamicImportSettled()
expect(hoisted.analytics.track.mock.calls).toEqual([
['execution_start', SOURCE],
['app:add_api_credit_button_clicked', SOURCE]
])
})
it('disables tracking when the SDK fails to load', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
hoisted.load.mockImplementation(() => {
throw new Error('network down')
})
const provider = createProvider()
provider.trackWorkflowExecution()
await vi.dynamicImportSettled()
provider.trackWorkflowExecution()
expect(hoisted.analytics.track).not.toHaveBeenCalled()
})
it('keeps tracking after an individual event fails to send', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const provider = createProvider()
await vi.dynamicImportSettled()
hoisted.analytics.track.mockImplementationOnce(() =>
Promise.reject(new Error('network blip'))
)
provider.trackWorkflowExecution()
provider.trackAddApiCreditButtonClicked()
expect(hoisted.analytics.track).toHaveBeenCalledTimes(2)
await vi.waitFor(() =>
expect(console.error).toHaveBeenCalledWith(
'Failed to track Customer.io event:',
expect.any(Error)
)
)
})
})

View File

@@ -1,142 +0,0 @@
import type { AnalyticsBrowser } from '@customerio/cdp-analytics-browser'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { TelemetryEvents } from '../../types'
import type {
AuthMetadata,
ExecutionSuccessMetadata,
ShareFlowMetadata,
SubscriptionMetadata,
TelemetryEventProperties,
TelemetryProvider,
TemplateLibraryMetadata,
TemplateMetadata
} from '../../types'
export const EVENT_SOURCE = 'web-sdk'
interface QueuedEvent {
event: string
properties: Record<string, unknown>
}
/**
* Customer.io (Data Pipelines) Telemetry Provider - Cloud Build Implementation
*
* CRITICAL: OSS Build Safety
* Only registered from the cloud-only initTelemetry, so the file and its SDK
* import are tree-shaken away in OSS/desktop builds.
*/
export class CustomerIoTelemetryProvider implements TelemetryProvider {
private analytics: AnalyticsBrowser | null = null
private isEnabled = true
private eventQueue: QueuedEvent[] = []
constructor() {
const {
write_key: writeKey,
site_id: siteId,
user_id: userIdOverride
} = window.__CONFIG__?.customer_io ?? {}
if (!writeKey || !siteId) {
this.isEnabled = false
return
}
void import('@customerio/cdp-analytics-browser')
.then(({ AnalyticsBrowser, InAppPlugin }) => {
const analytics = AnalyticsBrowser.load({ writeKey })
void analytics.register(
InAppPlugin({
siteId,
events: null,
anonymousInApp: false,
_env: undefined,
_logging: undefined,
colorScheme: 'system'
})
)
this.analytics = analytics
const currentUser = useCurrentUser()
if (userIdOverride) {
void analytics.identify(userIdOverride)
} else {
currentUser.onUserResolved((user) => void analytics.identify(user.id))
}
currentUser.onUserLogout(() => void analytics.reset())
this.flushQueue()
})
.catch((error) => {
console.error('Failed to load Customer.io:', error)
this.isEnabled = false
this.eventQueue = []
})
}
private send(event: string, properties: Record<string, unknown>): void {
void this.analytics?.track(event, properties)?.catch((error) => {
console.error('Failed to track Customer.io event:', error)
})
}
private track(event: string, metadata?: TelemetryEventProperties): void {
if (!this.isEnabled) return
const properties = { ...metadata, event_source: EVENT_SOURCE }
if (this.analytics) {
this.send(event, properties)
} else {
this.eventQueue.push({ event, properties })
}
}
private flushQueue(): void {
if (!this.analytics) return
for (const { event, properties } of this.eventQueue) {
this.send(event, properties)
}
this.eventQueue = []
}
trackAuth(metadata: AuthMetadata): void {
this.track(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
trackSubscription(
event: 'modal_opened' | 'subscribe_clicked',
metadata?: SubscriptionMetadata
): void {
this.track(
event === 'modal_opened'
? TelemetryEvents.SUBSCRIPTION_REQUIRED_MODAL_OPENED
: TelemetryEvents.SUBSCRIBE_NOW_BUTTON_CLICKED,
metadata
)
}
trackAddApiCreditButtonClicked(): void {
this.track(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}
trackWorkflowExecution(): void {
this.track(TelemetryEvents.EXECUTION_START)
}
trackExecutionSuccess(metadata: ExecutionSuccessMetadata): void {
this.track(TelemetryEvents.EXECUTION_SUCCESS, metadata)
}
trackTemplate(metadata: TemplateMetadata): void {
this.track(TelemetryEvents.TEMPLATE_WORKFLOW_OPENED, metadata)
}
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void {
this.track(TelemetryEvents.TEMPLATE_LIBRARY_OPENED, metadata)
}
trackShareFlow(metadata: ShareFlowMetadata): void {
this.track(TelemetryEvents.SHARE_FLOW, metadata)
}
}

View File

@@ -44,6 +44,13 @@ import {
getLocatorIdFromNodeData
} from '@/utils/graphTraversalUtil'
const TOOLTIP_VALUE_TYPES: Readonly<string[]> = [
'asset',
'combo',
'number',
'text'
]
interface ProcessedWidget {
advanced: boolean
handleContextMenu: (e: PointerEvent) => void
@@ -65,7 +72,7 @@ interface ProcessedWidget {
}
interface WidgetUiCallbacks {
getTooltipConfig: (widget: SafeWidgetData) => TooltipOptions
getTooltipConfig: (widget: SafeWidgetData, fullVal?: string) => TooltipOptions
handleNodeRightClick: (e: PointerEvent, nodeId: string) => void
}
@@ -316,7 +323,11 @@ export function computeProcessedWidgets({
executionErrorStore
)
const tooltipConfig = ui.getTooltipConfig(widget)
const valueTooltip =
TOOLTIP_VALUE_TYPES.includes(widget.type) && String(value).length > 10
? String(value)
: undefined
const tooltipConfig = ui.getTooltipConfig(widget, valueTooltip)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
@@ -372,7 +383,10 @@ export function useProcessedWidgets(
const { getWidgetTooltip, createTooltipConfig } = useNodeTooltips(nodeType)
const ui: WidgetUiCallbacks = {
getTooltipConfig: (widget) => createTooltipConfig(getWidgetTooltip(widget)),
getTooltipConfig: (widget, fullValue = '') =>
createTooltipConfig(
[getWidgetTooltip(widget), fullValue].join('\n\n').trim()
),
handleNodeRightClick
}

View File

@@ -34,7 +34,7 @@
<span
:class="
cn(
'min-w-[4ch] flex-1 truncate pr-3 pl-1 text-left',
'min-w-[4ch] flex-1 truncate pl-1 text-left',
$slots.default && 'mr-5'
)
"
@@ -42,12 +42,12 @@
{{ selectedLabel || placeholder || '\u00a0' }}
</span>
<span
class="flex h-full w-8 shrink-0 items-center justify-center rounded-r-lg"
class="flex h-full w-5 shrink-0 items-center justify-center rounded-r-lg"
>
<i
:class="
cn(
'icon-[lucide--chevron-down] size-4 translate-x-1.5',
'icon-[lucide--chevron-down]',
disabled
? 'bg-component-node-foreground-secondary'
: 'bg-muted-foreground'

View File

@@ -1,5 +1,4 @@
import { useTimeoutFn } from '@vueuse/core'
import { mapKeys } from 'es-toolkit'
import { defineStore } from 'pinia'
import { ref } from 'vue'
@@ -359,12 +358,8 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
function restoreOutputs(
outputs: Record<string, ExecutedWsMessage['output']>
) {
const parsedOutputs = mapKeys(
outputs,
(_, id) => executionIdToNodeLocatorId(app.rootGraph, id) ?? id
)
app.nodeOutputs = parsedOutputs
nodeOutputs.value = { ...parsedOutputs }
app.nodeOutputs = outputs
nodeOutputs.value = { ...outputs }
}
function updateNodeImages(node: LGraphNode) {