App mode - more updates & fixes (#9137)

## Summary

- fix sizing of sidebars in app mode
- update feedback button to match design
- update job queue notification
- clickable queue spinner item to allow clear queue
- refactor mode out of store to specific workflow instance
- support different saved vs active mode
- other styling/layout tweaks

## Changes

- **What**: Changes the store to a composable and moves the mode state
to the workflow.
- This enables switching between tabs and maintaining the mode they were
in

## Screenshots (if applicable)
<img width="1866" height="1455" alt="image"
src="https://github.com/user-attachments/assets/f9a8cd36-181f-4948-b48c-dd27bd9127cf"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9137-App-mode-more-updates-fixes-3106d73d365081a18ccff6ffe24fdec7)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
pythongosssss
2026-02-26 17:55:10 +00:00
committed by GitHub
parent ac12a3d9b9
commit 9fb93a5b0a
32 changed files with 689 additions and 236 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -124,7 +124,7 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
@@ -145,12 +145,12 @@ const unifiedWidth = computed(() =>
const { focusMode } = storeToRefs(workspaceStore)
const appModeStore = useAppModeStore()
const { mode } = useAppMode()
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
const showOffsideSplitter = computed(
() => rightSidePanelVisible.value || appModeStore.mode === 'builder:select'
() => rightSidePanelVisible.value || mode.value === 'builder:select'
)
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)

View File

@@ -8,12 +8,12 @@ import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemp
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const appModeStore = useAppModeStore()
const { enableAppBuilder, setMode } = useAppMode()
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
const isAssetsActive = computed(
@@ -24,7 +24,7 @@ const isWorkflowsActive = computed(
)
function enterBuilderMode() {
appModeStore.setMode('builder:select')
setMode('builder:select')
}
function openAssets() {
@@ -61,7 +61,7 @@ function openTemplates() {
</WorkflowActionsDropdown>
<Button
v-if="appModeStore.enableAppBuilder"
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions

View File

@@ -20,7 +20,7 @@
)
"
:aria-current="activeStep === step.id ? 'step' : undefined"
@click="appModeStore.setMode(step.id)"
@click="setMode(step.id)"
>
<StepBadge :step :index :model-value="activeStep" />
<StepLabel :step />
@@ -31,9 +31,9 @@
<!-- Save -->
<ConnectOutputPopover
v-if="!appModeStore.hasOutputs"
v-if="!hasOutputs"
:is-select-active="activeStep === 'builder:select'"
@switch="appModeStore.setMode('builder:select')"
@switch="setMode('builder:select')"
>
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
@@ -50,7 +50,7 @@
: 'hover:bg-secondary-background bg-transparent'
)
"
@click="appModeStore.setBuilderSaving(true)"
@click="setSaving(true)"
>
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" />
@@ -62,31 +62,25 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useEventListener } from '@vueuse/core'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import type { AppMode } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil'
import { useBuilderSave } from './useBuilderSave'
import ConnectOutputPopover from './ConnectOutputPopover.vue'
import StepBadge from './StepBadge.vue'
import StepLabel from './StepLabel.vue'
import type { BuilderToolbarStep } from './types'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { mode, setMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
const { saving, setSaving } = useBuilderSave()
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.preventDefault()
e.stopPropagation()
void appModeStore.exitBuilder()
})
const activeStep = computed(() =>
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
)
const activeStep = computed(() => (saving.value ? 'save' : mode.value))
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'

View File

@@ -1,30 +1,36 @@
import { watch } from 'vue'
import { ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
import { whenever } from '@vueuse/core'
const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success'
export function useBuilderSave() {
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const { toastErrorHandler } = useErrorHandling()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const dialogService = useDialogService()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore()
watch(
() => appModeStore.isBuilderSaving,
(saving) => {
if (saving) void onBuilderSave()
}
)
const saving = ref(false)
whenever(saving, onBuilderSave)
function setSaving(value: boolean) {
saving.value = value
}
async function onBuilderSave() {
const workflow = workflowStore.activeWorkflow
@@ -33,13 +39,14 @@ export function useBuilderSave() {
return
}
if (!workflow.isTemporary && workflow.activeState.extra?.linearMode) {
if (!workflow.isTemporary && workflow.initialMode != null) {
// Re-save with the previously chosen mode — no dialog needed.
try {
workflow.changeTracker?.checkState()
appModeStore.saveSelectedToWorkflow()
appModeStore.flushSelections()
await workflowService.saveWorkflow(workflow)
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
} catch {
showSuccessDialog(workflow.filename, workflow.initialMode === 'app')
} catch (e) {
toastErrorHandler(e)
resetSaving()
}
return
@@ -73,17 +80,19 @@ export function useBuilderSave() {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
appModeStore.saveSelectedToWorkflow()
appModeStore.flushSelections()
const mode = openAsApp ? 'app' : 'graph'
const saved = await workflowService.saveWorkflowAs(workflow, {
filename,
openAsApp
initialMode: mode
})
if (!saved) return
closeSaveDialog()
showSuccessDialog(filename, openAsApp)
} catch {
} catch (e) {
toastErrorHandler(e)
closeSaveDialog()
resetSaving()
}
@@ -97,7 +106,7 @@ export function useBuilderSave() {
workflowName,
savedAsApp,
onViewApp: () => {
appModeStore.setMode('app')
setMode('app')
closeSuccessDialog()
},
onClose: closeSuccessDialog
@@ -118,6 +127,8 @@ export function useBuilderSave() {
}
function resetSaving() {
appModeStore.setBuilderSaving(false)
saving.value = false
}
return { saving, setSaving }
}

View File

@@ -21,7 +21,7 @@
</div>
</div>
</template>
<template v-if="showUI && !appModeStore.isBuilderMode" #side-toolbar>
<template v-if="showUI && !isBuilderMode" #side-toolbar>
<SideToolbar />
</template>
<template v-if="showUI" #side-bar-panel>
@@ -31,27 +31,24 @@
<ExtensionSlot v-if="activeSidebarTab" :extension="activeSidebarTab" />
</div>
</template>
<template v-if="showUI && !appModeStore.isBuilderMode" #topmenu>
<template v-if="showUI && !isBuilderMode" #topmenu>
<TopMenuSection />
</template>
<template v-if="showUI" #bottom-panel>
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<AppBuilder v-if="appModeStore.mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!appModeStore.isBuilderMode" />
<AppBuilder v-if="mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!isBuilderMode" />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu
v-if="canvasMenuEnabled && !appModeStore.isBuilderMode"
v-if="canvasMenuEnabled && !isBuilderMode"
class="pointer-events-auto"
/>
<MiniMap
v-if="
comfyAppReady &&
minimapEnabled &&
betaMenuEnabled &&
!appModeStore.isBuilderMode
comfyAppReady && minimapEnabled && betaMenuEnabled && !isBuilderMode
"
class="pointer-events-auto"
/>
@@ -127,10 +124,10 @@ import {
import { useI18n } from 'vue-i18n'
import { isMiddlePointerInput } from '@/base/pointerUtils'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import TopMenuSection from '@/components/TopMenuSection.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import DomWidgets from '@/components/graph/DomWidgets.vue'
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
@@ -184,7 +181,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isNativeWindow } from '@/utils/envUtil'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -205,7 +202,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const appModeStore = useAppModeStore()
const { mode, isBuilderMode } = useAppMode()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()

View File

@@ -3,7 +3,7 @@
ref="containerRef"
:class="
cn(
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col w-full',
props.class
)
"

View File

@@ -10,7 +10,7 @@
@click="handleClick"
>
<i
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
v-if="workflowOption.workflow.initialMode === 'app'"
class="icon-[lucide--panels-top-left] bg-primary-background"
/>
<span

View File

@@ -25,15 +25,19 @@ whenever(feedbackRef, () => {
:href="`https://form.typeform.com/to/${dataTfWidget}`"
target="_blank"
variant="inverted"
class="rounded-full size-12"
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-question-mark] size-6" />
<i class="icon-[lucide--circle-help] size-4" />
</Button>
<Popover v-else>
<template #button>
<Button variant="inverted" class="rounded-full size-12" v-bind="$attrs">
<i class="icon-[lucide--circle-question-mark] size-6" />
<Button
variant="inverted"
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-help] size-4" />
</Button>
</template>
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget />

View File

@@ -0,0 +1,43 @@
import { computed, ref } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
export type AppMode = 'graph' | 'app' | 'builder:select' | 'builder:arrange'
const enableAppBuilder = ref(true)
export function useAppMode() {
const workflowStore = useWorkflowStore()
const mode = computed(
() =>
workflowStore.activeWorkflow?.activeMode ??
workflowStore.activeWorkflow?.initialMode ??
'graph'
)
const isBuilderMode = computed(
() => mode.value === 'builder:select' || mode.value === 'builder:arrange'
)
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isGraphMode = computed(
() => mode.value === 'graph' || mode.value === 'builder:select'
)
function setMode(newMode: AppMode) {
if (newMode === mode.value) return
const workflow = workflowStore.activeWorkflow
if (workflow) workflow.activeMode = newMode
}
return {
mode,
enableAppBuilder,
isBuilderMode,
isAppMode,
isGraphMode,
setMode
}
}

View File

@@ -1338,8 +1338,6 @@ export function useCoreCommands(): ComfyCommand[] {
typeof metadata?.source === 'string' ? metadata.source : 'keybind'
const newMode = !canvasStore.linearMode
if (newMode) useTelemetry()?.trackEnterLinear({ source })
app.rootGraph.extra.linearMode = newMode
workflowStore.activeWorkflow?.changeTracker?.checkState()
canvasStore.linearMode = newMode
}
}

View File

@@ -1159,8 +1159,8 @@
"queue": {
"initializingAlmostReady": "Initializing - Almost ready",
"inQueue": "In queue...",
"jobAddedToQueue": "Job added to queue",
"jobQueueing": "Job queueing",
"jobAddedToQueue": "Job queued",
"jobQueueing": "Job queuing",
"completedIn": "Finished in {duration}",
"jobMenu": {
"openAsWorkflowNewTab": "Open as workflow in new tab",
@@ -2996,7 +2996,8 @@
},
"linearMode": {
"linearMode": "App Mode",
"beta": "App Mode in Beta - Feedback",
"beta": "App mode in beta",
"giveFeedback": "Give feedback",
"graphMode": "Graph Mode",
"dragAndDropImage": "Click to browse or drag an image",
"runCount": "Number of runs",
@@ -3034,11 +3035,15 @@
"promptAddInputs": "Click on node parameters to add them here as inputs",
"noInputs": "No inputs added yet",
"inputsDesc": "Users will interact and adjust these to generate their outputs.",
"inputsExample": "Examples: Load image”, “Text prompt”, “Steps”",
"inputsExample": "Examples: \u201cLoad image\u201d, \u201cText prompt\u201d, \u201cSteps\u201d",
"promptAddOutputs": "Click on output nodes to add them here. These will be the generated results.",
"noOutputs": "No output nodes added yet",
"outputsDesc": "Connect at least one output node so users can see results after running.",
"outputsExample": "Examples: Save Image” or “Save Video"
"outputsExample": "Examples: \u201cSave Image\u201d or \u201cSave Video\u201d"
},
"queue": {
"clickToClear": "Click to clear queue",
"clear": "Clear queue"
}
},
"missingNodes": {

View File

@@ -2,12 +2,58 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PendingWarnings } from '@/platform/workflow/management/stores/comfyWorkflow'
import type {
LoadedComfyWorkflow,
PendingWarnings
} from '@/platform/workflow/management/stores/comfyWorkflow'
import { ComfyWorkflow as ComfyWorkflowClass } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { app } from '@/scripts/app'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
function createModeTestWorkflow(
options: {
path?: string
initialMode?: AppMode | null
activeMode?: AppMode | null
loaded?: boolean
} = {}
): LoadedComfyWorkflow {
const workflow = new ComfyWorkflowClass({
path: options.path ?? 'workflows/test.json',
modified: Date.now(),
size: 100
})
if ('initialMode' in options) workflow.initialMode = options.initialMode
workflow.activeMode = options.activeMode ?? null
if (options.loaded !== false) {
workflow.changeTracker = createMockChangeTracker()
workflow.content = '{}'
workflow.originalContent = '{}'
}
return workflow as LoadedComfyWorkflow
}
function makeWorkflowData(
extra: Record<string, unknown> = {}
): ComfyWorkflowJSON {
return {
last_node_id: 5,
last_link_id: 3,
nodes: [],
links: [],
groups: [],
config: {},
version: 0.4,
extra
}
}
const { mockShowMissingNodes, mockShowMissingModels } = vi.hoisted(() => ({
mockShowMissingNodes: vi.fn(),
@@ -72,6 +118,14 @@ vi.mock('@/stores/domWidgetStore', () => ({
})
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({
get workflow() {
return useWorkflowStore()
}
})
}))
const MISSING_MODELS: PendingWarnings['missingModels'] = {
missingModels: [
{ name: 'model.safetensors', url: '', directory: 'checkpoints' }
@@ -83,7 +137,7 @@ function createWorkflow(
warnings: PendingWarnings | null = null,
options: { loadable?: boolean; path?: string } = {}
): ComfyWorkflow {
return {
const wf = {
pendingWarnings: warnings,
...(options.loadable && {
path: options.path ?? 'workflows/test.json',
@@ -91,7 +145,8 @@ function createWorkflow(
activeState: { nodes: [], links: [] },
changeTracker: { reset: vi.fn(), restore: vi.fn() }
})
} as Partial<ComfyWorkflow> as ComfyWorkflow
} as Partial<ComfyWorkflow>
return wf as ComfyWorkflow
}
function enableWarningSettings() {
@@ -180,12 +235,7 @@ describe('useWorkflowService', () => {
workflowStore = useWorkflowStore()
vi.mocked(app.loadGraphData).mockImplementation(
async (_data, _clean, _restore, wf) => {
;(
workflowStore as Partial<Record<string, unknown>> as Record<
string,
unknown
>
).activeWorkflow = wf
workflowStore.activeWorkflow = wf as LoadedComfyWorkflow
}
)
})
@@ -256,4 +306,231 @@ describe('useWorkflowService', () => {
expect(mockShowMissingNodes).toHaveBeenCalledTimes(1)
})
})
describe('per-workflow mode switching', () => {
let appMode: ReturnType<typeof useAppMode>
let workflowStore: ReturnType<typeof useWorkflowStore>
let service: ReturnType<typeof useWorkflowService>
function mockOpenWorkflow() {
vi.spyOn(workflowStore, 'openWorkflow').mockImplementation(async (wf) => {
// Simulate load() setting changeTracker on first open
if (!wf.changeTracker) {
wf.changeTracker = createMockChangeTracker()
wf.content = '{}'
wf.originalContent = '{}'
}
const loaded = wf as LoadedComfyWorkflow
workflowStore.activeWorkflow = loaded
return loaded
})
}
beforeEach(() => {
appMode = useAppMode()
workflowStore = useWorkflowStore()
service = useWorkflowService()
})
describe('mode derivation from active workflow', () => {
it('reflects initialMode of the active workflow', () => {
const workflow = createModeTestWorkflow({ initialMode: 'app' })
workflowStore.activeWorkflow = workflow
expect(appMode.mode.value).toBe('app')
})
it('activeMode takes precedence over initialMode', () => {
const workflow = createModeTestWorkflow({
initialMode: 'app',
activeMode: 'graph'
})
workflowStore.activeWorkflow = workflow
expect(appMode.mode.value).toBe('graph')
})
it('defaults to graph when no active workflow', () => {
expect(appMode.mode.value).toBe('graph')
})
it('updates when activeWorkflow changes', () => {
const workflow1 = createModeTestWorkflow({
path: 'workflows/one.json',
initialMode: 'app'
})
const workflow2 = createModeTestWorkflow({
path: 'workflows/two.json',
activeMode: 'builder:select'
})
workflowStore.activeWorkflow = workflow1
expect(appMode.mode.value).toBe('app')
workflowStore.activeWorkflow = workflow2
expect(appMode.mode.value).toBe('builder:select')
})
})
describe('setMode writes to active workflow', () => {
it('writes activeMode without changing initialMode', () => {
const workflow = createModeTestWorkflow({ initialMode: 'graph' })
workflowStore.activeWorkflow = workflow
appMode.setMode('builder:arrange')
expect(workflow.activeMode).toBe('builder:arrange')
expect(workflow.initialMode).toBe('graph')
expect(appMode.mode.value).toBe('builder:arrange')
})
})
describe('afterLoadNewGraph initializes initialMode', () => {
beforeEach(() => {
mockOpenWorkflow()
})
it('sets initialMode from extra.linearMode on first load', async () => {
const workflow = createModeTestWorkflow({ loaded: false })
await service.afterLoadNewGraph(
workflow,
makeWorkflowData({ linearMode: true })
)
expect(workflow.initialMode).toBe('app')
})
it('leaves initialMode null when extra.linearMode is absent', async () => {
const workflow = createModeTestWorkflow({ loaded: false })
await service.afterLoadNewGraph(workflow, makeWorkflowData())
expect(workflow.initialMode).toBeNull()
})
it('sets initialMode to graph when extra.linearMode is false', async () => {
const workflow = createModeTestWorkflow({ loaded: false })
await service.afterLoadNewGraph(
workflow,
makeWorkflowData({ linearMode: false })
)
expect(workflow.initialMode).toBe('graph')
})
it('does not set initialMode on tab switch even if data has linearMode', async () => {
const workflow = createModeTestWorkflow({ loaded: false })
// First load — no linearMode in data
await service.afterLoadNewGraph(workflow, makeWorkflowData())
expect(workflow.initialMode).toBeNull()
// User switches to app mode at runtime
workflow.activeMode = 'app'
// Tab switch / reload — data now has linearMode (leaked from graph)
await service.afterLoadNewGraph(
workflow,
makeWorkflowData({ linearMode: true })
)
// initialMode should NOT have been updated — only builder save sets it
expect(workflow.initialMode).toBeNull()
})
it('preserves existing initialMode on tab switch', async () => {
const workflow = createModeTestWorkflow({
initialMode: 'app'
})
await service.afterLoadNewGraph(workflow, makeWorkflowData())
expect(workflow.initialMode).toBe('app')
})
it('sets initialMode to app for fresh string-based loads with linearMode', async () => {
vi.spyOn(workflowStore, 'createNewTemporary').mockReturnValue(
createModeTestWorkflow()
)
await service.afterLoadNewGraph(
'test.json',
makeWorkflowData({ linearMode: true })
)
expect(appMode.mode.value).toBe('app')
})
it('syncs linearMode to rootGraph.extra for draft persistence', async () => {
const workflow = createModeTestWorkflow({ loaded: false })
await service.afterLoadNewGraph(
workflow,
makeWorkflowData({ linearMode: true })
)
expect(app.rootGraph.extra.linearMode).toBe(true)
})
it('reads initialMode from file when draft lacks linearMode (restoration)', async () => {
const filePath = 'workflows/saved-app.json'
const fileInitialState = makeWorkflowData({ linearMode: true })
const mockTracker = createMockChangeTracker()
mockTracker.initialState = fileInitialState
// Persisted, not-loaded workflow in the store
const persistedWorkflow = new ComfyWorkflowClass({
path: filePath,
modified: Date.now(),
size: 100
})
vi.spyOn(workflowStore, 'getWorkflowByPath').mockReturnValue(
persistedWorkflow
)
vi.spyOn(workflowStore, 'openWorkflow').mockImplementation(
async (wf) => {
wf.changeTracker = mockTracker
wf.content = JSON.stringify(fileInitialState)
wf.originalContent = wf.content
workflowStore.activeWorkflow = wf as LoadedComfyWorkflow
return wf as LoadedComfyWorkflow
}
)
// Draft data has NO linearMode (simulates rootGraph serialization)
const draftData = makeWorkflowData()
await service.afterLoadNewGraph('saved-app.json', draftData)
// initialMode should come from the file, not the draft
expect(persistedWorkflow.initialMode).toBe('app')
expect(app.rootGraph.extra.linearMode).toBe(true)
})
})
describe('round-trip mode preservation', () => {
it('each workflow retains its own mode across tab switches', () => {
const workflow1 = createModeTestWorkflow({
path: 'workflows/one.json',
activeMode: 'builder:select'
})
const workflow2 = createModeTestWorkflow({
path: 'workflows/two.json',
initialMode: 'app'
})
workflowStore.activeWorkflow = workflow1
expect(appMode.mode.value).toBe('builder:select')
workflowStore.activeWorkflow = workflow2
expect(appMode.mode.value).toBe('app')
workflowStore.activeWorkflow = workflow1
expect(appMode.mode.value).toBe('builder:select')
})
})
})
})

View File

@@ -7,24 +7,31 @@ import type { Point, SerialisableGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { syncLinearMode } from '@/platform/workflow/management/stores/comfyWorkflow'
import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
import { useMissingModelsDialog } from '@/composables/useMissingModelsDialog'
import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { appendJsonExt } from '@/utils/formatUtil'
function linearModeToAppMode(linearMode: unknown): AppMode | null {
if (typeof linearMode !== 'boolean') return null
return linearMode ? 'app' : 'graph'
}
export const useWorkflowService = () => {
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
@@ -93,24 +100,20 @@ export const useWorkflowService = () => {
* Save a workflow as a new file
* @param workflow The workflow to save
* @param options.filename Pre-supplied filename (skips the prompt dialog)
* @param options.openAsApp If set, updates linearMode extra before saving
*/
const saveWorkflowAs = async (
workflow: ComfyWorkflow,
options: { filename?: string; openAsApp?: boolean } = {}
options: { filename?: string; initialMode?: AppMode } = {}
): Promise<boolean> => {
const newFilename = options.filename ?? (await workflow.promptSave())
if (!newFilename) return false
if (options.openAsApp !== undefined) {
app.rootGraph.extra ??= {}
app.rootGraph.extra.linearMode = options.openAsApp
workflow.changeTracker?.checkState()
}
const newPath = workflow.directory + '/' + appendJsonExt(newFilename)
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
const isSelfOverwrite =
existingWorkflow?.path === workflow.path && !existingWorkflow?.isTemporary
if (existingWorkflow && !existingWorkflow.isTemporary) {
const res = await dialogService.confirm({
title: t('sideToolbar.workflowTab.confirmOverwriteTitle'),
@@ -121,15 +124,20 @@ export const useWorkflowService = () => {
if (res !== true) return false
if (existingWorkflow.path === workflow.path) {
await saveWorkflow(workflow)
return true
if (!isSelfOverwrite) {
const deleted = await deleteWorkflow(existingWorkflow, true)
if (!deleted) return false
}
const deleted = await deleteWorkflow(existingWorkflow, true)
if (!deleted) return false
}
if (workflow.isTemporary) {
if (options.initialMode) workflow.initialMode = options.initialMode
syncLinearMode(workflow, [app.rootGraph], { flushLinearData: true })
workflow.changeTracker?.checkState()
if (isSelfOverwrite) {
await saveWorkflow(workflow)
} else if (workflow.isTemporary) {
await renameWorkflow(workflow, newPath)
await workflowStore.saveWorkflow(workflow)
} else {
@@ -148,6 +156,8 @@ export const useWorkflowService = () => {
if (workflow.isTemporary) {
await saveWorkflowAs(workflow)
} else {
syncLinearMode(workflow, [app.rootGraph], { flushLinearData: true })
workflow.changeTracker?.checkState()
await workflowStore.saveWorkflow(workflow)
}
}
@@ -358,13 +368,17 @@ export const useWorkflowService = () => {
workflowData: ComfyWorkflowJSON
) => {
const workflowStore = useWorkspaceStore().workflow
if (
workflowData.extra?.linearMode !== undefined ||
!workflowData.nodes.length
) {
if (workflowData.extra?.linearMode && !useCanvasStore().linearMode)
const { isAppMode } = useAppMode()
const wasAppMode = isAppMode.value
// Determine the initial app mode for fresh loads from serialized state.
// null means linearMode was never explicitly set (not builder-saved).
const freshLoadMode = linearModeToAppMode(workflowData.extra?.linearMode)
function trackIfEnteringApp(workflow: ComfyWorkflow) {
if (!wasAppMode && workflow.initialMode === 'app') {
useTelemetry()?.trackEnterLinear({ source: 'workflow' })
useCanvasStore().linearMode = !!workflowData.extra?.linearMode
}
}
if (value === null || typeof value === 'string') {
@@ -381,26 +395,39 @@ export const useWorkflowService = () => {
if (existingWorkflow?.isPersisted && !existingWorkflow.isLoaded) {
const loadedWorkflow =
await workflowStore.openWorkflow(existingWorkflow)
if (loadedWorkflow.initialMode === undefined) {
// Prefer the file's linearMode over the draft's since the file
// is the authoritative saved state.
loadedWorkflow.initialMode =
linearModeToAppMode(
loadedWorkflow.initialState?.extra?.linearMode
) ?? freshLoadMode
trackIfEnteringApp(loadedWorkflow)
}
syncLinearMode(loadedWorkflow, [workflowData, app.rootGraph])
loadedWorkflow.changeTracker.reset(workflowData)
loadedWorkflow.changeTracker.restore()
return
}
}
if (useCanvasStore().linearMode) {
app.rootGraph.extra ??= {}
app.rootGraph.extra.linearMode = true
}
const tempWorkflow = workflowStore.createNewTemporary(
path ? appendJsonExt(path) : undefined,
workflowData
)
tempWorkflow.initialMode = freshLoadMode
trackIfEnteringApp(tempWorkflow)
syncLinearMode(tempWorkflow, [workflowData, app.rootGraph])
await workflowStore.openWorkflow(tempWorkflow)
return
}
const loadedWorkflow = await workflowStore.openWorkflow(value)
if (loadedWorkflow.initialMode === undefined) {
loadedWorkflow.initialMode = freshLoadMode
trackIfEnteringApp(loadedWorkflow)
}
syncLinearMode(loadedWorkflow, [workflowData, app.rootGraph])
loadedWorkflow.changeTracker.reset(workflowData)
loadedWorkflow.changeTracker.restore()
}

View File

@@ -2,6 +2,8 @@ import { markRaw } from 'vue'
import { t } from '@/i18n'
import type { ChangeTracker } from '@/scripts/changeTracker'
import type { AppMode } from '@/composables/useAppMode'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { UserFile } from '@/stores/userFileStore'
import type {
ComfyWorkflowJSON,
@@ -9,6 +11,11 @@ import type {
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type { MissingNodeType } from '@/types/comfy'
export interface LinearData {
inputs: [NodeId, string][]
outputs: NodeId[]
}
export interface PendingWarnings {
missingNodeTypes?: MissingNodeType[]
missingModels?: {
@@ -17,6 +24,31 @@ export interface PendingWarnings {
}
}
type LinearModeTarget = { extra?: Record<string, unknown> | null } | null
export function syncLinearMode(
workflow: ComfyWorkflow,
targets: LinearModeTarget[],
options?: { flushLinearData?: boolean }
): void {
for (const target of targets) {
if (!target) continue
if (workflow.initialMode === 'app' || workflow.initialMode === 'graph') {
const extra = (target.extra ??= {})
extra.linearMode = workflow.initialMode === 'app'
} else {
delete target.extra?.linearMode
}
if (options?.flushLinearData && workflow.dirtyLinearData) {
const extra = (target.extra ??= {})
extra.linearData = workflow.dirtyLinearData
}
}
if (options?.flushLinearData && workflow.dirtyLinearData) {
workflow.dirtyLinearData = null
}
}
export class ComfyWorkflow extends UserFile {
static readonly basePath: string = 'workflows/'
readonly tintCanvasBg?: string
@@ -33,6 +65,23 @@ export class ComfyWorkflow extends UserFile {
* Warnings deferred from load time, shown when the workflow is first focused.
*/
pendingWarnings: PendingWarnings | null = null
/**
* Initial app mode derived from the serialized workflow (extra.linearMode).
* - `undefined`: not yet resolved (first load hasn't happened)
* - `null`: resolved, but no mode was set (never builder-saved)
* - `AppMode`: resolved to a specific mode
*/
initialMode: AppMode | null | undefined = undefined
/**
* Current app mode set by the user during the session.
* Takes precedence over initialMode when present.
*/
activeMode: AppMode | null = null
/**
* In-progress builder selections not yet persisted via save.
* Preserved across tab switches, discarded on exitBuilder.
*/
dirtyLinearData: LinearData | null = null
/**
* @param options The path, modified, and size of the workflow.
@@ -129,6 +178,7 @@ export class ComfyWorkflow extends UserFile {
override unload(): void {
this.changeTracker = null
this.activeMode = null
super.unload()
}

View File

@@ -249,6 +249,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
modified: Date.now(),
size: -1
})
workflow.initialMode = existingWorkflow.initialMode
workflow.originalContent = workflow.content = JSON.stringify(state)
workflowLookup.value[workflow.path] = workflow
return workflow

View File

@@ -3,7 +3,7 @@ import { defineStore } from 'pinia'
import { computed, markRaw, ref, shallowRef } from 'vue'
import type { Raw } from 'vue'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
import type {
@@ -44,10 +44,11 @@ export const useCanvasStore = defineStore('canvas', () => {
// Reactive scale percentage that syncs with app.canvas.ds.scale
const appScalePercentage = ref(100)
const { isAppMode, setMode } = useAppMode()
const linearMode = computed({
get: () => useAppModeStore().isAppMode,
get: () => isAppMode.value,
set: (val: boolean) => {
useAppModeStore().setMode(val ? 'app' : 'graph')
setMode(val ? 'app' : 'graph')
}
})

View File

@@ -1,15 +1,18 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import Button from '@/components/ui/button/Button.vue'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
</script>
<template>
<div
v-if="appModeStore.hasOutputs"
v-if="hasOutputs"
role="article"
data-testid="arrange-preview"
class="flex flex-col items-center justify-center h-full w-3/4 gap-6 p-8 mx-auto"
@@ -47,11 +50,7 @@ const appModeStore = useAppModeStore()
<p class="mt-0 p-0">{{ t('linearMode.arrange.outputExamples') }}</p>
</div>
<div class="flex flex-row gap-2">
<Button
variant="primary"
size="lg"
@click="appModeStore.setMode('builder:select')"
>
<Button variant="primary" size="lg" @click="setMode('builder:select')">
{{ t('linearMode.arrange.switchToSelectButton') }}
</Button>
</div>

View File

@@ -26,6 +26,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const commandStore = useCommandStore()
@@ -35,7 +36,9 @@ const { batchCount } = storeToRefs(useQueueSettingsStore())
const settingStore = useSettingStore()
const { isActiveSubscription } = useBillingContext()
const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const props = defineProps<{
toastTo?: string | HTMLElement
@@ -168,7 +171,7 @@ defineExpose({ runButtonClick })
</script>
<template>
<div
v-if="!appModeStore.isBuilderMode && appModeStore.hasOutputs"
v-if="!isBuilderMode && hasOutputs"
class="flex flex-col min-w-80 md:h-full"
>
<section
@@ -299,7 +302,7 @@ defineExpose({ runButtonClick })
<Button
v-else
variant="primary"
class="w-full mt-4"
class="w-full mt-4 text-sm"
size="lg"
@click="runButtonClick"
>
@@ -315,14 +318,18 @@ defineExpose({ runButtonClick })
:to="toastTo"
>
<div
class="bg-base-foreground text-base-background rounded-sm flex h-8 p-1 pr-2 gap-2 items-center"
class="bg-secondary-background text-base-foreground rounded-lg flex h-8 p-1 pr-2 gap-2 items-center"
>
<i
v-if="jobFinishedQueue"
class="icon-[lucide--check] size-5 bg-success-background"
class="icon-[lucide--check] size-5 text-muted-foreground"
/>
<i v-else class="icon-[lucide--loader-circle] size-4 animate-spin" />
<span v-text="t('queue.jobAddedToQueue')" />
<span
v-text="
jobFinishedQueue ? t('queue.jobAddedToQueue') : t('queue.jobQueueing')
"
/>
</div>
</Teleport>
</template>

View File

@@ -27,13 +27,13 @@ import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/stores/queueStore'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
const { t } = useI18n()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const mediaActions = useMediaAssetActions()
const queueStore = useQueueStore()
const appModeStore = useAppModeStore()
const { mode: appModeValue } = useAppMode()
const { runButtonClick } = defineProps<{
runButtonClick?: (e: Event) => void
mobile?: boolean
@@ -165,7 +165,7 @@ async function rerun(e: Event) {
:model-url="selectedOutput!.url"
/>
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
<LinearArrange v-else-if="appModeStore.mode === 'builder:arrange'" />
<LinearArrange v-else-if="appModeValue === 'builder:arrange'" />
<LinearWelcome v-else />
<OutputHistory @update-selection="handleSelection" />
<OutputHistory class="not-md:mx-40" @update-selection="handleSelection" />
</template>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import Button from '@/components/ui/button/Button.vue'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { setMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
</script>
<template>
@@ -24,7 +27,7 @@ const appModeStore = useAppModeStore()
<p class="mt-0">{{ t('linearMode.welcome.controls') }}</p>
<p class="mt-0">{{ t('linearMode.welcome.sharing') }}</p>
</div>
<div v-if="appModeStore.hasOutputs" class="flex flex-row gap-2 text-[14px]">
<div v-if="hasOutputs" class="flex flex-row gap-2 text-[14px]">
<p class="mt-0 text-base-foreground">
<i18n-t keypath="linearMode.welcome.getStarted" tag="span">
<template #runButton>
@@ -38,18 +41,10 @@ const appModeStore = useAppModeStore()
</p>
</div>
<div v-else class="flex flex-row gap-2">
<Button
variant="textonly"
size="lg"
@click="appModeStore.setMode('graph')"
>
<Button variant="textonly" size="lg" @click="setMode('graph')">
{{ t('linearMode.welcome.backToWorkflow') }}
</Button>
<Button
variant="primary"
size="lg"
@click="appModeStore.setMode('builder:select')"
>
<Button variant="primary" size="lg" @click="setMode('builder:select')">
<i class="icon-[lucide--hammer]" />
{{ t('linearMode.welcome.buildApp') }}
<div

View File

@@ -12,6 +12,7 @@ import {
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import OutputHistoryActiveQueueItem from '@/renderer/extensions/linearMode/OutputHistoryActiveQueueItem.vue'
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
import type {
@@ -275,7 +276,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
orientation="horizontal"
selection-behavior="replace"
by="id"
class="min-w-0"
class="min-w-0 px-4 pb-4"
@update:model-value="onSelectionChange"
>
<ListboxContent as-child>
@@ -287,23 +288,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
>
<div class="flex items-center gap-0.5 mx-auto w-fit">
<div v-if="queueCount > 0" class="shrink-0 flex items-center gap-0.5">
<div
class="shrink-0 p-1 border-2 border-transparent relative"
data-testid="linear-job"
>
<div
class="size-10 rounded-sm bg-secondary-background flex items-center justify-center"
>
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
</div>
<div
v-if="queueCount > 1"
class="absolute top-0 right-0 min-w-4 h-4 flex justify-center items-center rounded-full bg-primary-background text-text-primary text-xs"
v-text="queueCount"
/>
</div>
<OutputHistoryActiveQueueItem :queue-count="queueCount" />
<div
v-if="hasActiveContent || visibleHistory.length > 0"
class="border-l border-border-default h-12 shrink-0 mx-4"

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCommandStore } from '@/stores/commandStore'
const { queueCount } = defineProps<{
queueCount: number
}>()
const { t } = useI18n()
const commandStore = useCommandStore()
function clearQueue(close: () => void) {
void commandStore.execute('Comfy.ClearPendingTasks')
close()
}
</script>
<template>
<div
class="shrink-0 p-1 border-2 border-transparent relative"
data-testid="linear-job"
>
<Popover side="top" :show-arrow="false" @focus-outside.prevent>
<template #button>
<Button
v-tooltip.top="t('linearMode.queue.clickToClear')"
:aria-label="t('linearMode.queue.clickToClear')"
variant="textonly"
size="unset"
class="size-10 rounded-sm bg-secondary-background flex items-center justify-center"
>
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
</Button>
</template>
<template #default="{ close }">
<Button
:disabled="queueCount === 0"
variant="textonly"
class="text-destructive-background px-4 text-sm"
@click="clearQueue(close)"
>
<i class="icon-[lucide--list-x]" />
{{ t('linearMode.queue.clear') }}
</Button>
</template>
</Popover>
<div
v-if="queueCount > 1"
aria-hidden="true"
class="absolute top-0 right-0 min-w-4 h-4 flex justify-center items-center rounded-full bg-primary-background text-text-primary text-xs"
v-text="queueCount"
/>
</div>
</template>

View File

@@ -14,11 +14,9 @@ const { apiTarget } = vi.hoisted(() => ({
apiTarget: new EventTarget()
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => ({
get isAppMode() {
return isAppModeRef.value
}
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
isAppMode: isAppModeRef
})
}))

View File

@@ -5,12 +5,12 @@ import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeO
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
import { useExecutionStore } from '@/stores/executionStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
export const useLinearOutputStore = defineStore('linearOutput', () => {
const appModeStore = useAppModeStore()
const { isAppMode } = useAppMode()
const executionStore = useExecutionStore()
const jobPreviewStore = useJobPreviewStore()
@@ -220,7 +220,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
watch(
() => executionStore.activeJobId,
(jobId, oldJobId) => {
if (!appModeStore.isAppMode) return
if (!isAppMode.value) return
if (oldJobId && oldJobId !== jobId) {
onJobComplete(oldJobId)
}
@@ -233,7 +233,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
watch(
() => jobPreviewStore.previewsByPromptId,
(previews) => {
if (!appModeStore.isAppMode) return
if (!isAppMode.value) return
const jobId = executionStore.activeJobId
if (!jobId) return
const url = previews[jobId]
@@ -243,7 +243,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
)
watch(
() => appModeStore.isAppMode,
isAppMode,
(active, wasActive) => {
if (active) {
api.addEventListener('executed', handleExecuted)

View File

@@ -1,66 +1,67 @@
import { defineStore } from 'pinia'
import { whenever } from '@vueuse/core'
import { reactive, readonly, computed, ref, watch } from 'vue'
import { reactive, computed, watch } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { t } from '@/i18n'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useDialogService } from '@/services/dialogService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
export type AppMode = 'graph' | 'app' | 'builder:select' | 'builder:arrange'
export const useAppModeStore = defineStore('appMode', () => {
const { getCanvas } = useCanvasStore()
const workflowStore = useWorkflowStore()
const { mode, setMode, isBuilderMode } = useAppMode()
const selectedInputs = reactive<[NodeId, string][]>([])
const selectedOutputs = reactive<NodeId[]>([])
const mode = ref<AppMode>('graph')
const builderSaving = ref(false)
const hasOutputs = computed(() => !!selectedOutputs.length)
const enableAppBuilder = ref(true)
const isBuilderMode = computed(
() => mode.value === 'builder:select' || mode.value === 'builder:arrange'
)
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isGraphMode = computed(
() => mode.value === 'graph' || mode.value === 'builder:select'
)
const isBuilderSaving = computed(
() => builderSaving.value && isBuilderMode.value
)
function loadSelections(data: Partial<LinearData> | undefined) {
selectedInputs.splice(0, selectedInputs.length, ...(data?.inputs ?? []))
selectedOutputs.splice(0, selectedOutputs.length, ...(data?.outputs ?? []))
}
function resetSelectedToWorkflow() {
const { activeWorkflow } = workflowStore
if (!activeWorkflow) return
const { activeState } = activeWorkflow.changeTracker
selectedInputs.splice(
0,
selectedInputs.length,
...(activeState.extra?.linearData?.inputs ?? [])
)
selectedOutputs.splice(
0,
selectedOutputs.length,
...(activeState.extra?.linearData?.outputs ?? [])
)
loadSelections(activeWorkflow.changeTracker?.activeState?.extra?.linearData)
}
function saveSelectedToWorkflow() {
app.rootGraph.extra ??= {}
app.rootGraph.extra.linearData = {
inputs: [...selectedInputs],
outputs: [...selectedOutputs]
function flushSelections() {
const workflow = workflowStore.activeWorkflow
if (workflow) {
workflow.dirtyLinearData = {
inputs: [...selectedInputs],
outputs: [...selectedOutputs]
}
}
}
whenever(() => workflowStore.activeWorkflow, resetSelectedToWorkflow, {
immediate: true
})
watch(
() => workflowStore.activeWorkflow,
(newWorkflow, oldWorkflow) => {
// Persist in-progress builder selections to the outgoing workflow
if (oldWorkflow && isBuilderMode.value) {
oldWorkflow.dirtyLinearData = {
inputs: [...selectedInputs],
outputs: [...selectedOutputs]
}
}
// Load from incoming workflow: dirty state first, then persisted
if (newWorkflow) {
loadSelections(
newWorkflow.dirtyLinearData ??
newWorkflow.changeTracker?.activeState?.extra?.linearData
)
} else {
loadSelections(undefined)
}
},
{ immediate: true }
)
watch(
() => mode.value === 'builder:select',
@@ -76,30 +77,18 @@ export const useAppModeStore = defineStore('appMode', () => {
)
return
const workflow = workflowStore.activeWorkflow
if (workflow) workflow.dirtyLinearData = null
resetSelectedToWorkflow()
mode.value = 'graph'
setMode('graph')
}
return {
mode: readonly(mode),
enableAppBuilder: readonly(enableAppBuilder),
exitBuilder,
isBuilderMode,
isAppMode,
isGraphMode,
isBuilderSaving,
hasOutputs,
flushSelections,
resetSelectedToWorkflow,
saveSelectedToWorkflow,
selectedInputs,
selectedOutputs,
setBuilderSaving: (newBuilderSaving: boolean) => {
if (!isBuilderMode.value) return
builderSaving.value = newBuilderSaving
},
setMode: (newMode: AppMode) => {
if (newMode === mode.value) return
mode.value = newMode
}
selectedOutputs
}
})

View File

@@ -226,7 +226,10 @@ export function createMockChangeTracker(
undoQueue: [],
redoQueue: [],
changeCount: 0,
checkState: vi.fn(),
reset: vi.fn(),
restore: vi.fn(),
store: vi.fn(),
...overrides
}
return partial as Partial<ChangeTracker> as ChangeTracker

View File

@@ -13,7 +13,7 @@
<GraphCanvas @ready="onGraphReady" />
</div>
<LinearView v-if="linearMode" />
<BuilderToolbar v-if="appModeStore.isBuilderMode" />
<BuilderToolbar v-if="isBuilderMode" />
</div>
<GlobalToast />
@@ -70,7 +70,7 @@ import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { setupAutoQueueHandler } from '@/services/autoQueueService'
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
import { useAppModeStore } from '@/stores/appModeStore'
import { useAppMode } from '@/composables/useAppMode'
import { useAssetsStore } from '@/stores/assetsStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -88,12 +88,10 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { electronAPI } from '@/utils/envUtil'
import BuilderToolbar from '@/components/builder/BuilderToolbar.vue'
import { useBuilderSave } from '@/components/builder/useBuilderSave'
import LinearView from '@/views/LinearView.vue'
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
setupAutoQueueHandler()
useBuilderSave()
useProgressFavicon()
useBrowserTabTitle()
@@ -106,7 +104,7 @@ const queueStore = useQueueStore()
const assetsStore = useAssetsStore()
const versionCompatibilityStore = useVersionCompatibilityStore()
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
const appModeStore = useAppModeStore()
const { isBuilderMode } = useAppMode()
const { linearMode } = storeToRefs(useCanvasStore())
const telemetry = useTelemetry()

View File

@@ -2,6 +2,7 @@
import { breakpointsTailwind, unrefElement, useBreakpoints } from '@vueuse/core'
import Splitter from 'primevue/splitter'
import SplitterPanel from 'primevue/splitterpanel'
import { storeToRefs } from 'pinia'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -12,17 +13,20 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@/utils/tailwindUtil'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBar.vue'
import MobileMenu from '@/renderer/extensions/linearMode/MobileMenu.vue'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const appModeStore = useAppModeStore()
const { isBuilderMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
@@ -31,10 +35,14 @@ const sidebarOnLeft = computed(
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
)
const hasLeftPanel = computed(
() => (sidebarOnLeft.value && activeTab.value) || !sidebarOnLeft.value
() =>
(sidebarOnLeft.value && activeTab.value) ||
(!sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value)
)
const hasRightPanel = computed(
() => sidebarOnLeft.value || (!sidebarOnLeft.value && activeTab.value)
() =>
(sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value) ||
(!sidebarOnLeft.value && activeTab.value)
)
const bottomLeftRef = useTemplateRef('bottomLeftRef')
@@ -105,22 +113,27 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
class="absolute top-0 left-0 w-[calc(100%+16px)] z-21"
/>
<LinearPreview :run-button-click="linearWorkflowRef?.runButtonClick" />
<div class="absolute z-21 top-1 left-1">
<AppModeToolbar v-if="!appModeStore.isBuilderMode" />
<div class="absolute z-21 top-4 left-4">
<AppModeToolbar v-if="!isBuilderMode" />
</div>
<div ref="bottomLeftRef" class="absolute z-20 bottom-4 left-4" />
<div ref="bottomRightRef" class="absolute z-20 bottom-24 right-4" />
<div ref="bottomLeftRef" class="absolute z-20 bottom-7 left-4" />
<div ref="bottomRightRef" class="absolute z-20 bottom-7 right-4" />
<div
class="absolute z-20 bottom-4 right-4 text-base-foreground flex items-center gap-4"
:class="
cn(
'absolute z-20 bottom-4 text-base-foreground flex items-center gap-2',
sidebarOnLeft ? 'left-4' : 'right-4'
)
"
>
<TypeformPopoverButton
data-tf-widget="gmVqFi8l"
:align="
settingStore.get('Comfy.Sidebar.Location') === 'left'
? 'end'
: 'start'
"
:align="sidebarOnLeft ? 'start' : 'end'"
/>
<div class="flex flex-col text-sm text-muted-foreground">
<span>{{ t('linearMode.beta') }}</span>
<span>{{ t('linearMode.giveFeedback') }}</span>
</div>
</div>
</SplitterPanel>
<SplitterPanel