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

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