Merge branch 'main' into refactor/rebuild-select-reka-ui-9700

This commit is contained in:
Dante
2026-03-12 14:11:21 +09:00
committed by GitHub
21 changed files with 287 additions and 30 deletions

View File

@@ -4,6 +4,8 @@ name: 'CI: Tests Storybook'
on:
workflow_dispatch: # Allow manual triggering
pull_request:
push:
branches: [main]
jobs:
# Post starting comment for non-forked PRs
@@ -138,6 +140,29 @@ jobs:
"${{ github.head_ref }}" \
"completed"
# Deploy Storybook to production URL on main branch push
deploy-production:
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Build Storybook
run: pnpm build-storybook
- name: Deploy to Cloudflare Pages (production)
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
npx wrangler@^4.0.0 pages deploy storybook-static \
--project-name=comfy-storybook \
--branch=main
# Update comment with Chromatic URLs for version-bump branches
update-comment-with-chromatic:
needs: [chromatic-deployment, deploy-and-comment]

1
global.d.ts vendored
View File

@@ -35,6 +35,7 @@ interface Window {
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
posthog_debug?: boolean
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number

View File

@@ -43,6 +43,9 @@ export function useAppSetDefaultView() {
const extra = (app.rootGraph.extra ??= {})
extra.linearMode = openAsApp
workflow.changeTracker?.checkState()
useTelemetry()?.trackDefaultViewSet({
default_view: openAsApp ? 'app' : 'graph'
})
closeDialog()
showAppliedDialog(openAsApp)
}

View File

@@ -324,7 +324,8 @@ function safeWidgetMapper(
}
: (extractWidgetDisplayOptions(effectiveWidget) ?? options),
slotMetadata: slotInfo,
slotName: name !== widget.name ? widget.name : undefined
slotName: name !== widget.name ? widget.name : undefined,
tooltip: widget.tooltip
}
} catch (error) {
return {

View File

@@ -27,7 +27,7 @@ function addDynamicCombo(node: LGraphNode, inputs: DynamicInputs) {
`${namePrefix}.${depth}.${inputIndex}`,
Array.isArray(input)
? ['COMFY_DYNAMICCOMBO_V3', { options: getSpec(input, depth + 1) }]
: [input, {}]
: [input, { tooltip: `${groupIndex}` }]
])
return {
key: `${groupIndex}`,
@@ -106,6 +106,13 @@ describe('Dynamic Combos', () => {
expect(node.inputs[1].name).toBe('0.0.0.0')
expect(node.inputs[3].name).toBe('2.2.0.0')
})
test('Dynamically added widgets have tooltips', () => {
const node = testNode()
addDynamicCombo(node, [['INT'], ['STRING']])
expect.soft(node.widgets[1].tooltip).toBe('0')
node.widgets[0].value = '1'
expect.soft(node.widgets[1].tooltip).toBe('1')
})
})
describe('Autogrow', () => {
const inputsSpec = { required: { image: ['IMAGE', {}] } }

View File

@@ -31,6 +31,7 @@ export type RemoteConfig = {
mixpanel_token?: string
posthog_project_token?: string
posthog_api_host?: string
posthog_debug?: boolean
subscription_required?: boolean
server_health_alert?: ServerHealthAlert
max_upload_size?: number

View File

@@ -3,6 +3,7 @@ import type { AuditLog } from '@/services/customerEventsService'
import type {
AuthMetadata,
BeginCheckoutMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionErrorMetadata,
@@ -27,7 +28,8 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
WorkflowImportMetadata,
WorkflowSavedMetadata
} from './types'
/**
@@ -157,6 +159,14 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackWorkflowOpened?.(metadata))
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.dispatch((provider) => provider.trackWorkflowSaved?.(metadata))
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.dispatch((provider) => provider.trackDefaultViewSet?.(metadata))
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.dispatch((provider) => provider.trackEnterLinear?.(metadata))
}

View File

@@ -14,6 +14,7 @@ import { getExecutionContext } from '../../utils/getExecutionContext'
import type {
AuthMetadata,
CreditTopupMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
@@ -40,7 +41,8 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
@@ -359,6 +361,14 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}

View File

@@ -80,27 +80,30 @@ describe('PostHogTelemetryProvider', () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith('phc_test_token', {
api_host: 'https://t.comfy.org',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie',
debug: false
})
expect(hoisted.mockInit).toHaveBeenCalledWith(
'phc_test_token',
expect.objectContaining({
api_host: 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie'
})
)
})
it('uses custom api_host from config when provided', async () => {
it('enables debug mode when posthog_debug is true in config', async () => {
window.__CONFIG__ = {
posthog_project_token: 'phc_test_token',
posthog_api_host: 'https://custom.host.com'
posthog_debug: true
} as typeof window.__CONFIG__
new PostHogTelemetryProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith(
'phc_test_token',
expect.objectContaining({ api_host: 'https://custom.host.com' })
expect.objectContaining({ debug: true })
)
})

View File

@@ -7,6 +7,7 @@ import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type {
AuthMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ExecutionContext,
@@ -34,7 +35,8 @@ import type {
TemplateMetadata,
UiButtonClickMetadata,
WorkflowCreatedMetadata,
WorkflowImportMetadata
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { getExecutionContext } from '../../utils/getExecutionContext'
@@ -102,11 +104,14 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.posthog!.init(apiKey, {
api_host:
window.__CONFIG__?.posthog_api_host || 'https://t.comfy.org',
ui_host: 'https://us.posthog.com',
autocapture: false,
capture_pageview: false,
capture_pageleave: false,
persistence: 'localStorage+cookie',
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true'
debug:
window.__CONFIG__?.posthog_debug ??
import.meta.env.VITE_POSTHOG_DEBUG === 'true'
})
this.isInitialized = true
this.flushEventQueue()
@@ -344,6 +349,14 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.WORKFLOW_OPENED, metadata)
}
trackWorkflowSaved(metadata: WorkflowSavedMetadata): void {
this.trackEvent(TelemetryEvents.WORKFLOW_SAVED, metadata)
}
trackDefaultViewSet(metadata: DefaultViewSetMetadata): void {
this.trackEvent(TelemetryEvents.DEFAULT_VIEW_SET, metadata)
}
trackEnterLinear(metadata: EnterLinearMetadata): void {
this.trackEvent(TelemetryEvents.ENTER_LINEAR_MODE, metadata)
}

View File

@@ -149,6 +149,15 @@ export interface EnterLinearMetadata {
source?: string
}
export interface WorkflowSavedMetadata {
is_app: boolean
is_new: boolean
}
export interface DefaultViewSetMetadata {
default_view: 'app' | 'graph'
}
type ShareFlowStep =
| 'dialog_opened'
| 'save_prompted'
@@ -377,6 +386,8 @@ export interface TelemetryProvider {
// Workflow management events
trackWorkflowImported?(metadata: WorkflowImportMetadata): void
trackWorkflowOpened?(metadata: WorkflowImportMetadata): void
trackWorkflowSaved?(metadata: WorkflowSavedMetadata): void
trackDefaultViewSet?(metadata: DefaultViewSetMetadata): void
trackEnterLinear?(metadata: EnterLinearMetadata): void
trackShareFlow?(metadata: ShareFlowMetadata): void
@@ -490,6 +501,8 @@ export const TelemetryEvents = {
// Workflow Creation
WORKFLOW_CREATED: 'app:workflow_created',
WORKFLOW_SAVED: 'app:workflow_saved',
DEFAULT_VIEW_SET: 'app:default_view_set',
// Execution Lifecycle
EXECUTION_START: 'execution_start',
@@ -540,4 +553,6 @@ export type TelemetryEventProperties =
| WorkflowCreatedMetadata
| EnterLinearMetadata
| ShareFlowMetadata
| WorkflowSavedMetadata
| DefaultViewSetMetadata
| SubscriptionMetadata

View File

@@ -149,6 +149,8 @@ export const useWorkflowService = () => {
await openWorkflow(tempWorkflow)
await workflowStore.saveWorkflow(tempWorkflow)
}
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: true })
return true
}
@@ -189,6 +191,7 @@ export const useWorkflowService = () => {
}
await workflowStore.saveWorkflow(workflow)
useTelemetry()?.trackWorkflowSaved({ is_app: isApp, is_new: false })
}
}

View File

@@ -187,6 +187,46 @@ describe('useMinimapViewport', () => {
expect(transform.height).toBeCloseTo(viewportHeight * 0.5) // 300 * 0.5 = 150
})
it('should maintain strict reference equality for viewportTransform when canvas state is unchanged', () => {
vi.mocked(calculateNodeBounds).mockReturnValue({
minX: 0,
minY: 0,
maxX: 500,
maxY: 400,
width: 500,
height: 400
})
vi.mocked(enforceMinimumBounds).mockImplementation((bounds) => bounds)
vi.mocked(calculateMinimapScale).mockReturnValue(0.5)
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
const graphRef = ref(mockGraph) as Ref<LGraph | null>
const viewport = useMinimapViewport(canvasRef, graphRef, 250, 200)
mockCanvas.ds.scale = 2
mockCanvas.ds.offset = [-100, -50]
viewport.updateBounds()
viewport.updateCanvasDimensions()
viewport.updateViewport()
const initialTransform = viewport.viewportTransform.value
viewport.updateViewport()
const transformAfterIdle = viewport.viewportTransform.value
expect(transformAfterIdle).toBe(initialTransform)
mockCanvas.ds.offset = [-150, -50]
viewport.updateViewport()
const transformAfterPan = viewport.viewportTransform.value
expect(transformAfterPan).not.toBe(initialTransform)
expect(transformAfterPan.x).not.toBe(initialTransform.x)
})
it('should center view on world coordinates', () => {
const canvasRef = ref(mockCanvas) as Ref<MinimapCanvas | null>
const graphRef = ref(mockGraph) as Ref<LGraph | null>

View File

@@ -90,12 +90,16 @@ export function useMinimapViewport(
const centerOffsetX = (width - bounds.value.width * scale.value) / 2
const centerOffsetY = (height - bounds.value.height * scale.value) / 2
viewportTransform.value = {
x: (worldX - bounds.value.minX) * scale.value + centerOffsetX,
y: (worldY - bounds.value.minY) * scale.value + centerOffsetY,
width: viewportWidth * scale.value,
height: viewportHeight * scale.value
}
const x = (worldX - bounds.value.minX) * scale.value + centerOffsetX
const y = (worldY - bounds.value.minY) * scale.value + centerOffsetY
const w = viewportWidth * scale.value
const h = viewportHeight * scale.value
const curr = viewportTransform.value
if (curr.x === x && curr.y === y && curr.width === w && curr.height === h)
return
viewportTransform.value = { x, y, width: w, height: h }
}
const updateBounds = () => {

View File

@@ -15,7 +15,34 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import { createMockWidget } from './widgetTestUtils'
const mockCheckState = vi.hoisted(() => vi.fn())
const mockAssetsData = vi.hoisted(() => ({ items: [] as AssetItem[] }))
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const actual = await vi.importActual(
'@/platform/workflow/management/stores/workflowStore'
)
return {
...actual,
useWorkflowStore: () => ({
activeWorkflow: {
changeTracker: {
checkState: mockCheckState
}
}
})
}
})
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
apiURL: vi.fn((url: string) => url),
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
vi.mock(
'@/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData',
() => ({
@@ -456,3 +483,69 @@ describe('WidgetSelectDropdown cloud asset mode (COM-14333)', () => {
expect(selectedSet.has('missing-missing_model.safetensors')).toBe(true)
})
})
describe('WidgetSelectDropdown undo tracking', () => {
interface UndoTrackingInstance extends ComponentPublicInstance {
updateSelectedItems: (selectedSet: Set<string>) => void
handleFilesUpdate: (files: File[]) => Promise<void>
}
const mountForUndo = (
widget: SimplifiedWidget<string | undefined>,
modelValue: string | undefined
): VueWrapper<UndoTrackingInstance> => {
return mount(WidgetSelectDropdown, {
props: {
widget,
modelValue,
assetKind: 'image',
allowUpload: true,
uploadFolder: 'input'
},
global: {
plugins: [PrimeVue, createTestingPinia(), i18n]
}
}) as unknown as VueWrapper<UndoTrackingInstance>
}
beforeEach(() => {
mockCheckState.mockClear()
})
it('calls checkState after dropdown selection changes modelValue', () => {
const widget = createMockWidget<string | undefined>({
value: 'img_001.png',
name: 'test_image',
type: 'combo',
options: { values: ['img_001.png', 'photo_abc.jpg'] }
})
const wrapper = mountForUndo(widget, 'img_001.png')
wrapper.vm.updateSelectedItems(new Set(['input-1']))
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['photo_abc.jpg'])
expect(mockCheckState).toHaveBeenCalledOnce()
})
it('calls checkState after file upload completes', async () => {
const { api } = await import('@/scripts/api')
vi.mocked(api.fetchApi).mockResolvedValue({
status: 200,
json: () => Promise.resolve({ name: 'uploaded.png', subfolder: '' })
} as Response)
const widget = createMockWidget<string | undefined>({
value: 'img_001.png',
name: 'test_image',
type: 'combo',
options: { values: ['img_001.png'] }
})
const wrapper = mountForUndo(widget, 'img_001.png')
const file = new File(['test'], 'uploaded.png', { type: 'image/png' })
await wrapper.vm.handleFilesUpdate([file])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['uploaded.png'])
expect(mockCheckState).toHaveBeenCalledOnce()
})
})

View File

@@ -17,6 +17,7 @@ import {
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import FormDropdown from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/FormDropdown.vue'
import type {
FilterOption,
@@ -376,6 +377,7 @@ function updateSelectedItems(selectedItems: Set<string>) {
return
}
modelValue.value = name
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
}
const uploadFile = async (
@@ -450,6 +452,9 @@ async function handleFilesUpdate(files: File[]) {
if (props.widget.callback) {
props.widget.callback(uploadedPaths[0])
}
// 5. Snapshot undo state so the image change gets its own undo entry
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
} catch (error) {
console.error('Upload error:', error)
toastStore.addAlert(`Upload failed: ${error}`)

View File

@@ -40,6 +40,7 @@ function addMultilineWidget(
})
widget.element = inputEl
widget.inputEl = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('input', (event) => {

View File

@@ -216,14 +216,18 @@ export const useLitegraphService = () => {
*/
function addNodeInput(node: LGraphNode, inputSpec: InputSpec) {
addInputSocket(node, inputSpec)
addInputWidget(node, inputSpec)
addInputWidget(node, inputSpec, { dynamic: true })
}
/**
* @internal Add a widget to the node. For both primitive types and custom widgets
* (unless `socketless`), an input socket is also added.
*/
function addInputWidget(node: LGraphNode, inputSpec: InputSpec) {
function addInputWidget(
node: LGraphNode,
inputSpec: InputSpec,
{ dynamic }: { dynamic?: boolean } = {}
) {
const widgetInputSpec = { ...inputSpec }
if (inputSpec.widgetType) {
widgetInputSpec.type = inputSpec.widgetType
@@ -254,6 +258,7 @@ export const useLitegraphService = () => {
advanced: inputSpec.advanced,
hidden: inputSpec.hidden
})
if (dynamic) widget.tooltip = inputSpec.tooltip
}
if (!widget?.options?.socketless) {

View File

@@ -74,6 +74,8 @@ export interface SimplifiedWidget<
/** Optional input specification backing this widget */
spec?: InputSpecV2
tooltip?: string
controlWidget?: SafeControlWidget
}

View File

@@ -402,7 +402,7 @@ const isUnresolvedTab = computed(
)
// Map of tab IDs to their empty state i18n key suffixes
const tabEmptyStateKeys: Partial<Record<ManagerTab, string>> = {
const tabEmptyStateKeys: Record<string, string> = {
[ManagerTab.AllInstalled]: 'allInstalled',
[ManagerTab.UpdateAvailable]: 'updateAvailable',
[ManagerTab.Conflicting]: 'conflicting',
@@ -410,12 +410,27 @@ const tabEmptyStateKeys: Partial<Record<ManagerTab, string>> = {
[ManagerTab.Missing]: 'missing'
}
const managerApiDependentTabs = new Set<string>([
ManagerTab.AllInstalled,
ManagerTab.UpdateAvailable,
ManagerTab.Conflicting,
ManagerTab.NotInstalled,
ManagerTab.Missing
])
const isManagerErrorRelevant = computed(() => {
const tabId = selectedTab.value?.id
return (
!!comfyManagerStore.error && !!tabId && managerApiDependentTabs.has(tabId)
)
})
// Empty state messages based on current tab and search state
const emptyStateTitle = computed(() => {
if (comfyManagerStore.error) return t('manager.errorConnecting')
if (isManagerErrorRelevant.value) return t('manager.errorConnecting')
if (searchQuery.value) return t('manager.noResultsFound')
const tabId = selectedTab.value?.id as ManagerTab | undefined
const tabId = selectedTab.value?.id
const emptyStateKey = tabId ? tabEmptyStateKeys[tabId] : undefined
return emptyStateKey
@@ -424,7 +439,7 @@ const emptyStateTitle = computed(() => {
})
const emptyStateMessage = computed(() => {
if (comfyManagerStore.error) return t('manager.tryAgainLater')
if (isManagerErrorRelevant.value) return t('manager.tryAgainLater')
if (searchQuery.value) {
const baseMessage = t('manager.tryDifferentSearch')
if (isLegacyManagerSearch.value) {
@@ -433,7 +448,7 @@ const emptyStateMessage = computed(() => {
return baseMessage
}
const tabId = selectedTab.value?.id as ManagerTab | undefined
const tabId = selectedTab.value?.id
const emptyStateKey = tabId ? tabEmptyStateKeys[tabId] : undefined
return emptyStateKey