From 5376b7ed1e7b4cf81fce6765d30639228cd42529 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:27:05 +0000 Subject: [PATCH] feat: App mode empty graph handling (#9393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) image ┆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) --- src/components/appMode/AppModeToolbar.vue | 10 ++-- .../builder/EmptyWorkflowDialogContent.vue | 6 +-- src/locales/en/main.json | 9 ++-- .../extensions/linearMode/LinearWelcome.vue | 48 ++++++++++++----- src/scripts/app.ts | 52 ++++++++++++------- src/stores/appModeStore.test.ts | 9 ++-- src/stores/appModeStore.ts | 9 +++- 7 files changed, 91 insertions(+), 52 deletions(-) diff --git a/src/components/appMode/AppModeToolbar.vue b/src/components/appMode/AppModeToolbar.vue index c4bd31747f..f4b6a8ec19 100644 --- a/src/components/appMode/AppModeToolbar.vue +++ b/src/components/appMode/AppModeToolbar.vue @@ -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" diff --git a/src/components/builder/EmptyWorkflowDialogContent.vue b/src/components/builder/EmptyWorkflowDialogContent.vue index 8951d83bc5..e790478b30 100644 --- a/src/components/builder/EmptyWorkflowDialogContent.vue +++ b/src/components/builder/EmptyWorkflowDialogContent.vue @@ -6,7 +6,7 @@

- {{ $t('builderToolbar.emptyWorkflowExplanation') }} + {{ $t('linearMode.emptyWorkflowExplanation') }}

{{ $t('builderToolbar.emptyWorkflowPrompt') }} @@ -19,10 +19,10 @@ size="lg" @click="$emit('backToWorkflow')" > - {{ $t('builderToolbar.backToWorkflow') }} + {{ $t('linearMode.backToWorkflow') }} diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 61fe4fe4c6..c7b3ffd326 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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" diff --git a/src/renderer/extensions/linearMode/LinearWelcome.vue b/src/renderer/extensions/linearMode/LinearWelcome.vue index 8670d7812e..2a629000a2 100644 --- a/src/renderer/extensions/linearMode/LinearWelcome.vue +++ b/src/renderer/extensions/linearMode/LinearWelcome.vue @@ -1,6 +1,7 @@

diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 2c40dbfe85..b21ae96ea5 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -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) { diff --git a/src/stores/appModeStore.test.ts b/src/stores/appModeStore.test.ts index a9cbe78b1b..50db79ee46 100644 --- a/src/stores/appModeStore.test.ts +++ b/src/stores/appModeStore.test.ts @@ -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() diff --git a/src/stores/appModeStore.ts b/src/stores/appModeStore.ts index 62ff5ad559..645fa7b58e 100644 --- a/src/stores/appModeStore.ts +++ b/src/stores/appModeStore.ts @@ -19,6 +19,12 @@ export const useAppModeStore = defineStore('appMode', () => { const selectedInputs = reactive<[NodeId, string][]>([]) const selectedOutputs = reactive([]) 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 | 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,