feat: App mode empty graph handling (#9393)

## Summary

Adds handling for entering app mode with an empty graph prompting the
user to load a template as a starting point

## Changes

- **What**: 
- app mode handle empty workflows, disable builder button, show
different message
- fix fitView when switching from app mode to graph

## Review Focus

Moving the fitView since the canvas is hidden in app mode until after
the workflow is loaded and the mode has been switched back to graph, I
don't see how this could cause any issues but worth a closer eye

## Screenshots (if applicable)

<img width="1057" height="916" alt="image"
src="https://github.com/user-attachments/assets/2ffe2b6d-9ce1-4218-828a-b7bc336c365a"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9393-feat-App-mode-empty-graph-handling-3196d73d3650812cab0ce878109ed5c9)
by [Unito](https://www.unito.io)
This commit is contained in:
pythongosssss
2026-03-05 10:27:05 +00:00
committed by GitHub
parent e0089d93d0
commit 5376b7ed1e
7 changed files with 91 additions and 52 deletions

View File

@@ -4,18 +4,21 @@ import { useI18n } from 'vue-i18n'
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useAppModeStore } from '@/stores/appModeStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const { enableAppBuilder } = useAppMode()
const { enterBuilder } = useAppModeStore()
const appModeStore = useAppModeStore()
const { enterBuilder } = appModeStore
const { hasNodes } = storeToRefs(appModeStore)
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
const isAssetsActive = computed(
@@ -71,6 +74,7 @@ function openTemplates() {
}"
variant="secondary"
size="unset"
:disabled="!hasNodes"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilder"

View File

@@ -6,7 +6,7 @@
<div class="flex flex-col gap-2">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.emptyWorkflowExplanation') }}
{{ $t('linearMode.emptyWorkflowExplanation') }}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('builderToolbar.emptyWorkflowPrompt') }}
@@ -19,10 +19,10 @@
size="lg"
@click="$emit('backToWorkflow')"
>
{{ $t('builderToolbar.backToWorkflow') }}
{{ $t('linearMode.backToWorkflow') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('loadTemplate')">
{{ $t('builderToolbar.loadTemplate') }}
{{ $t('linearMode.loadTemplate') }}
</Button>
</template>
</BuilderDialog>

View File

@@ -3037,13 +3037,15 @@
"downloadAll": "Download All",
"viewJob": "View Job",
"enterNodeGraph": "Enter node graph",
"emptyWorkflowExplanation": "Your workflow is empty. You need some nodes first to start building an app.",
"backToWorkflow": "Back to workflow",
"loadTemplate": "Load a template",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",
"controls": "Your outputs appear at the bottom, your controls are on the right. Everything else stays out of the way.",
"sharing": "Share your workflow as a simple tool anyone can use. Export it from the tab menu and when others open it, they'll see App Mode. No node graph knowledge needed.",
"getStarted": "Click {runButton} to get started.",
"backToWorkflow": "Back to workflow",
"buildApp": "Build app"
},
"appModeToolbar": {
@@ -3388,10 +3390,7 @@
"defaultModeAppliedGraphPrompt": "Would you like to view the app still?",
"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"
"emptyWorkflowPrompt": "Do you want to start with a template?"
},
"builderMenu": {
"exitAppBuilder": "Exit app builder"

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useAppModeStore } from '@/stores/appModeStore'
import Button from '@/components/ui/button/Button.vue'
import { storeToRefs } from 'pinia'
@@ -8,7 +9,8 @@ import { storeToRefs } from 'pinia'
const { t } = useI18n()
const { setMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { hasOutputs, hasNodes } = storeToRefs(appModeStore)
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
</script>
<template>
@@ -41,19 +43,37 @@ const { hasOutputs } = storeToRefs(appModeStore)
</i18n-t>
</p>
</div>
<div v-else class="flex flex-row gap-2">
<Button variant="textonly" size="lg" @click="setMode('graph')">
{{ t('linearMode.welcome.backToWorkflow') }}
</Button>
<Button variant="primary" size="lg" @click="appModeStore.enterBuilder()">
<i class="icon-[lucide--hammer]" />
{{ t('linearMode.welcome.buildApp') }}
<div
class="bg-base-foreground text-base-background text-xxs rounded-full absolute -top-2 -right-2 px-1"
<template v-else>
<p v-if="!hasNodes" class="mt-0 text-base-foreground text-sm max-w-md">
{{ t('linearMode.emptyWorkflowExplanation') }}
</p>
<div class="flex flex-row gap-2">
<Button variant="textonly" size="lg" @click="setMode('graph')">
{{ t('linearMode.backToWorkflow') }}
</Button>
<Button
v-if="!hasNodes"
variant="secondary"
size="lg"
@click="templateSelectorDialog.show('appbuilder')"
>
{{ t('g.experimental') }}
</div>
</Button>
</div>
{{ t('linearMode.loadTemplate') }}
</Button>
<Button
v-else
variant="primary"
size="lg"
@click="appModeStore.enterBuilder()"
>
<i class="icon-[lucide--hammer]" />
{{ t('linearMode.welcome.buildApp') }}
<div
class="bg-base-foreground text-base-background text-xxs rounded-full absolute -top-2 -right-2 px-1"
>
{{ t('g.experimental') }}
</div>
</Button>
</div>
</template>
</div>
</template>

View File

@@ -1289,26 +1289,8 @@ export class ComfyApp {
}
}
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.rootGraph.configure(graphData)
// Save original renderer version before scaling (it gets modified during scaling)
const originalMainGraphRenderer =
this.rootGraph.extra.workflowRendererVersion
// Scale main graph
ensureCorrectLayoutScale(originalMainGraphRenderer)
// Scale all subgraphs that were loaded with the workflow
// Use original main graph renderer as fallback (not the modified one)
for (const subgraph of this.rootGraph.subgraphs.values()) {
ensureCorrectLayoutScale(
subgraph.extra.workflowRendererVersion || originalMainGraphRenderer,
subgraph
)
}
const canvasVisible = !!(this.canvasEl.width && this.canvasEl.height)
const fitView = () => {
if (
restore_view &&
useSettingStore().get('Comfy.EnableWorkflowViewRestore')
@@ -1336,6 +1318,29 @@ export class ComfyApp {
useLitegraphService().fitView()
}
}
}
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.rootGraph.configure(graphData)
// Save original renderer version before scaling (it gets modified during scaling)
const originalMainGraphRenderer =
this.rootGraph.extra.workflowRendererVersion
// Scale main graph
ensureCorrectLayoutScale(originalMainGraphRenderer)
// Scale all subgraphs that were loaded with the workflow
// Use original main graph renderer as fallback (not the modified one)
for (const subgraph of this.rootGraph.subgraphs.values()) {
ensureCorrectLayoutScale(
subgraph.extra.workflowRendererVersion || originalMainGraphRenderer,
subgraph
)
}
if (canvasVisible) fitView()
} catch (error) {
useDialogService().showErrorDialog(error, {
title: t('errorDialog.loadWorkflowTitle'),
@@ -1415,6 +1420,13 @@ export class ComfyApp {
this.rootGraph.serialize() as unknown as ComfyWorkflowJSON
)
// If the canvas was not visible and we're a fresh load, resize the canvas and fit the view
// This fixes switching from app mode to a new graph mode workflow (e.g. load template)
if (!canvasVisible && (!workflow || typeof workflow === 'string')) {
this.canvas.resize()
requestAnimationFrame(() => fitView())
}
// Store pending warnings on the workflow for deferred display
const activeWf = useWorkspaceStore().workflow.activeWorkflow
if (activeWf) {

View File

@@ -130,8 +130,8 @@ describe('appModeStore', () => {
})
describe('empty workflow dialog callbacks', () => {
function getDialogOptions() {
vi.mocked(app.rootGraph).nodes = []
function getDialogOptions(nodes: LGraphNode[] = []) {
vi.mocked(app.rootGraph).nodes = nodes
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
return mockEmptyWorkflowDialog.lastOptions
@@ -149,10 +149,7 @@ describe('appModeStore', () => {
})
it('onEnterBuilder enters builder when nodes exist', () => {
const options = getDialogOptions()
// Simulate template having loaded nodes
vi.mocked(app.rootGraph).nodes = [{ id: 1 } as LGraphNode]
const options = getDialogOptions([{ id: 1 } as LGraphNode])
options.onEnterBuilder()

View File

@@ -19,6 +19,12 @@ export const useAppModeStore = defineStore('appMode', () => {
const selectedInputs = reactive<[NodeId, string][]>([])
const selectedOutputs = reactive<NodeId[]>([])
const hasOutputs = computed(() => !!selectedOutputs.length)
const hasNodes = computed(() => {
// Nodes are not reactive, so trigger recomputation when workflow changes
void workflowStore.activeWorkflow
void mode.value
return !!app.rootGraph?.nodes?.length
})
function loadSelections(data: Partial<LinearData> | undefined) {
const rawInputs = data?.inputs ?? []
@@ -91,7 +97,7 @@ export const useAppModeStore = defineStore('appMode', () => {
})
function enterBuilder() {
if (!app.rootGraph?.nodes?.length) {
if (!hasNodes.value) {
emptyWorkflowDialog.show({
onEnterBuilder: () => enterBuilder(),
onDismiss: () => setMode('graph')
@@ -114,6 +120,7 @@ export const useAppModeStore = defineStore('appMode', () => {
return {
enterBuilder,
exitBuilder,
hasNodes,
hasOutputs,
resetSelectedToWorkflow,
selectedInputs,