mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-07 22:20:03 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user