feat: Show empty workflow dialog when entering app builder with no nodes (#9379)

## Summary

Prompts users to load a template or return to graph when entering
builder mode on an empty workflow

## Screenshots (if applicable)

<img width="627" height="275" alt="image"
src="https://github.com/user-attachments/assets/c1a35dc3-4e8f-4abd-95b9-2f92524e8ebf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9379-feat-Show-empty-workflow-dialog-when-entering-app-builder-with-no-nodes-3196d73d36508123b643ec893cd86cac)
by [Unito](https://www.unito.io)
This commit is contained in:
pythongosssss
2026-03-04 20:15:56 +00:00
committed by GitHub
parent f084a60708
commit 31276ff2a6
9 changed files with 221 additions and 48 deletions

View File

@@ -13,6 +13,7 @@
</h2>
</div>
<Button
v-if="showClose"
variant="muted-textonly"
class="-mr-1"
:aria-label="$t('g.close')"
@@ -37,6 +38,10 @@
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { showClose = true } = defineProps<{
showClose?: boolean
}>()
defineEmits<{
close: []
}>()

View File

@@ -0,0 +1,40 @@
<template>
<BuilderDialog :show-close="false">
<template #title>
{{ $t('builderToolbar.emptyWorkflowTitle') }}
</template>
<div class="flex flex-col gap-2">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.emptyWorkflowExplanation') }}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.emptyWorkflowPrompt') }}
</p>
</div>
<template #footer>
<Button
variant="muted-textonly"
size="lg"
@click="$emit('backToWorkflow')"
>
{{ $t('builderToolbar.backToWorkflow') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('loadTemplate')">
{{ $t('builderToolbar.loadTemplate') }}
</Button>
</template>
</BuilderDialog>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import BuilderDialog from './BuilderDialog.vue'
defineEmits<{
backToWorkflow: []
loadTemplate: []
}>()
</script>

View File

@@ -0,0 +1,44 @@
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import EmptyWorkflowDialogContent from './EmptyWorkflowDialogContent.vue'
const DIALOG_KEY = 'builder-empty-workflow'
export function useEmptyWorkflowDialog() {
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
function show(options: {
onEnterBuilder: () => void
onDismiss: () => void
}) {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
component: EmptyWorkflowDialogContent,
props: {
onBackToWorkflow: () => {
closeDialog()
options.onDismiss()
},
onLoadTemplate: () => {
closeDialog()
templateSelectorDialog.show('appbuilder', {
afterClose: () => {
if (app.rootGraph?.nodes?.length) options.onEnterBuilder()
}
})
}
}
})
}
function closeDialog() {
dialogStore.closeDialog({ key: DIALOG_KEY })
}
return { show }
}

View File

@@ -107,6 +107,32 @@ describe('useWorkflowTemplateSelectorDialog', () => {
)
})
it('invokes afterClose callback when dialog is closed', () => {
mockNewUserService.isNewUser.mockReturnValue(false)
const afterClose = vi.fn()
const dialog = useWorkflowTemplateSelectorDialog()
dialog.show('command', { afterClose })
const onClose =
mockDialogService.showLayoutDialog.mock.calls[0][0].props.onClose
onClose()
expect(mockDialogStore.closeDialog).toHaveBeenCalled()
expect(afterClose).toHaveBeenCalled()
})
it('does not fail when afterClose is not provided', () => {
mockNewUserService.isNewUser.mockReturnValue(false)
const dialog = useWorkflowTemplateSelectorDialog()
dialog.show('command')
const onClose =
mockDialogService.showLayoutDialog.mock.calls[0][0].props.onClose
expect(() => onClose()).not.toThrow()
})
it('tracks telemetry with source', () => {
mockNewUserService.isNewUser.mockReturnValue(false)

View File

@@ -1,5 +1,6 @@
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
import { useTelemetry } from '@/platform/telemetry'
import type { TemplateLibraryMetadata } from '@/platform/telemetry/types'
import { useDialogService } from '@/services/dialogService'
import { useNewUserService } from '@/services/useNewUserService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -17,8 +18,8 @@ export const useWorkflowTemplateSelectorDialog = () => {
}
function show(
source: 'sidebar' | 'menu' | 'command' = 'command',
options?: { initialCategory?: string }
source: TemplateLibraryMetadata['source'] = 'command',
options?: { initialCategory?: string; afterClose?: () => void }
) {
useTelemetry()?.trackTemplateLibraryOpened({ source })
@@ -30,7 +31,10 @@ export const useWorkflowTemplateSelectorDialog = () => {
key: DIALOG_KEY,
component: WorkflowTemplateSelectorDialog,
props: {
onClose: hide,
onClose: () => {
hide()
options?.afterClose?.()
},
initialCategory
}
})

View File

@@ -2593,7 +2593,7 @@
"duplicate": "Duplicate",
"enterAppMode": "Enter app mode",
"exitAppMode": "Exit app mode",
"enterBuilderMode": "Enter app builder",
"enterBuilderMode": "App builder",
"workflowActions": "Workflow actions",
"clearWorkflow": "Clear Workflow",
"deleteWorkflow": "Delete Workflow",
@@ -3379,7 +3379,12 @@
"defaultModeAppliedAppPrompt": "Would you like to view it now?",
"defaultModeAppliedGraphBody": "This workflow will open as a node graph by default from now on.",
"defaultModeAppliedGraphPrompt": "Would you like to view the app still?",
"viewApp": "View app"
"viewApp": "View app",
"emptyWorkflowTitle": "This workflow has no nodes",
"emptyWorkflowExplanation": "Your workflow is empty. You need some nodes first to start building an app.",
"emptyWorkflowPrompt": "Do you want to start with a template?",
"backToWorkflow": "Back to workflow",
"loadTemplate": "Load a template"
},
"builderMenu": {
"exitAppBuilder": "Exit app builder"

View File

@@ -158,7 +158,7 @@ export type WorkflowOpenSource = NonNullable<
* Template library metadata
*/
export interface TemplateLibraryMetadata {
source: 'sidebar' | 'menu' | 'command'
source: 'sidebar' | 'menu' | 'command' | 'appbuilder'
}
/**

View File

@@ -3,17 +3,29 @@ import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/comfyWorkflow'
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { ChangeTracker } from '@/scripts/changeTracker'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
const mockEmptyWorkflowDialog = vi.hoisted(() => {
let lastOptions: { onEnterBuilder: () => void; onDismiss: () => void }
return {
show: vi.fn((options: typeof lastOptions) => {
lastOptions = options
}),
get lastOptions() {
return lastOptions
}
}
})
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { extra: {} }
rootGraph: { extra: {}, nodes: [{ id: 1 }] }
}
}))
@@ -31,6 +43,10 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
})
}))
vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
useEmptyWorkflowDialog: () => mockEmptyWorkflowDialog
}))
import { useAppModeStore } from './appModeStore'
function createBuilderWorkflow(
@@ -49,19 +65,22 @@ function createBuilderWorkflow(
}
describe('appModeStore', () => {
let workflowStore: ReturnType<typeof useWorkflowStore>
let store: ReturnType<typeof useAppModeStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
vi.mocked(app.rootGraph).extra = {}
mockResolveNode.mockReturnValue(undefined)
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
workflowStore = useWorkflowStore()
store = useAppModeStore()
vi.clearAllMocks()
})
describe('enterBuilder', () => {
it('navigates to builder:arrange when in app mode with outputs', () => {
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = createBuilderWorkflow('app')
const store = useAppModeStore()
store.selectedOutputs.push(1)
store.enterBuilder()
@@ -70,21 +89,15 @@ describe('appModeStore', () => {
})
it('navigates to builder:select when in app mode without outputs', () => {
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = createBuilderWorkflow('app')
const store = useAppModeStore()
store.enterBuilder()
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:select')
})
it('navigates to builder:select when in graph mode with outputs', () => {
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
const store = useAppModeStore()
store.selectedOutputs.push(1)
store.enterBuilder()
@@ -93,15 +106,67 @@ describe('appModeStore', () => {
})
it('navigates to builder:select when in graph mode without outputs', () => {
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
const store = useAppModeStore()
store.enterBuilder()
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:select')
})
it('shows empty workflow dialog when graph has no nodes', () => {
vi.mocked(app.rootGraph).nodes = []
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
expect(mockEmptyWorkflowDialog.show).toHaveBeenCalledWith(
expect.objectContaining({
onEnterBuilder: expect.any(Function),
onDismiss: expect.any(Function)
})
)
expect(workflowStore.activeWorkflow!.activeMode).toBe('graph')
})
})
describe('empty workflow dialog callbacks', () => {
function getDialogOptions() {
vi.mocked(app.rootGraph).nodes = []
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
return mockEmptyWorkflowDialog.lastOptions
}
it('onDismiss sets graph mode', () => {
const options = getDialogOptions()
// Move to builder so onDismiss must actually transition back
workflowStore.activeWorkflow!.activeMode = 'builder:select'
options.onDismiss()
expect(workflowStore.activeWorkflow!.activeMode).toBe('graph')
})
it('onEnterBuilder enters builder when nodes exist', () => {
const options = getDialogOptions()
// Simulate template having loaded nodes
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
options.onEnterBuilder()
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:select')
})
it('onEnterBuilder shows dialog again when no nodes', () => {
const options = getDialogOptions()
mockEmptyWorkflowDialog.show.mockClear()
options.onEnterBuilder()
expect(mockEmptyWorkflowDialog.show).toHaveBeenCalled()
})
})
describe('loadSelections pruning', () => {
@@ -135,9 +200,6 @@ describe('appModeStore', () => {
id == 1 ? (node1 as unknown as LGraphNode) : undefined
)
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
workflowStore.activeWorkflow = workflowWithLinearData(
[
[1, 'prompt'],
@@ -156,9 +218,6 @@ describe('appModeStore', () => {
id == 1 ? (node1 as unknown as LGraphNode) : undefined
)
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
workflowStore.activeWorkflow = workflowWithLinearData(
[
[1, 'prompt'],
@@ -180,9 +239,6 @@ describe('appModeStore', () => {
id == 1 ? (node1 as unknown as LGraphNode) : undefined
)
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
workflowStore.activeWorkflow = workflowWithLinearData([], [1, 99])
await nextTick()
@@ -192,9 +248,6 @@ describe('appModeStore', () => {
it('hasOutputs is false when all output nodes are deleted', async () => {
mockResolveNode.mockReturnValue(undefined)
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
workflowStore.activeWorkflow = workflowWithLinearData([], [10, 20])
await nextTick()
@@ -205,9 +258,6 @@ describe('appModeStore', () => {
describe('linearData sync watcher', () => {
it('writes linearData to rootGraph.extra when in builder mode', async () => {
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
workflowStore.activeWorkflow = createBuilderWorkflow()
await nextTick()
@@ -221,12 +271,7 @@ describe('appModeStore', () => {
})
it('does not write linearData when not in builder mode', async () => {
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
const workflow = createBuilderWorkflow()
workflow.activeMode = 'graph'
workflowStore.activeWorkflow = workflow
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
await nextTick()
store.selectedOutputs.push(1)
@@ -236,9 +281,6 @@ describe('appModeStore', () => {
})
it('does not write when rootGraph is null', async () => {
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
workflowStore.activeWorkflow = createBuilderWorkflow()
await nextTick()
@@ -255,9 +297,6 @@ describe('appModeStore', () => {
})
it('reflects input changes in linearData', async () => {
const workflowStore = useWorkflowStore()
const store = useAppModeStore()
workflowStore.activeWorkflow = createBuilderWorkflow()
await nextTick()

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { reactive, computed, watch } from 'vue'
import { useEmptyWorkflowDialog } from '@/components/builder/useEmptyWorkflowDialog'
import { useAppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
@@ -13,6 +14,7 @@ export const useAppModeStore = defineStore('appMode', () => {
const { getCanvas } = useCanvasStore()
const workflowStore = useWorkflowStore()
const { mode, setMode, isBuilderMode } = useAppMode()
const emptyWorkflowDialog = useEmptyWorkflowDialog()
const selectedInputs = reactive<[NodeId, string][]>([])
const selectedOutputs = reactive<NodeId[]>([])
@@ -92,6 +94,14 @@ export const useAppModeStore = defineStore('appMode', () => {
)
function enterBuilder() {
if (!app.rootGraph?.nodes?.length) {
emptyWorkflowDialog.show({
onEnterBuilder: () => enterBuilder(),
onDismiss: () => setMode('graph')
})
return
}
setMode(
mode.value === 'app' && hasOutputs.value
? 'builder:arrange'