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

View File

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

View File

@@ -20,7 +20,7 @@
) )
" "
:aria-current="activeStep === step.id ? 'step' : undefined" :aria-current="activeStep === step.id ? 'step' : undefined"
@click="appModeStore.setMode(step.id)" @click="setMode(step.id)"
> >
<StepBadge :step :index :model-value="activeStep" /> <StepBadge :step :index :model-value="activeStep" />
<StepLabel :step /> <StepLabel :step />
@@ -31,9 +31,9 @@
<!-- Save --> <!-- Save -->
<ConnectOutputPopover <ConnectOutputPopover
v-if="!appModeStore.hasOutputs" v-if="!hasOutputs"
:is-select-active="activeStep === 'builder:select'" :is-select-active="activeStep === 'builder:select'"
@switch="appModeStore.setMode('builder:select')" @switch="setMode('builder:select')"
> >
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')"> <button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" /> <StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
@@ -50,7 +50,7 @@
: 'hover:bg-secondary-background bg-transparent' : 'hover:bg-secondary-background bg-transparent'
) )
" "
@click="appModeStore.setBuilderSaving(true)" @click="setSaving(true)"
> >
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" /> <StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
<StepLabel :step="saveStep" /> <StepLabel :step="saveStep" />
@@ -62,31 +62,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n' 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 { useAppModeStore } from '@/stores/appModeStore'
import type { AppMode } from '@/stores/appModeStore'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
import { useBuilderSave } from './useBuilderSave'
import ConnectOutputPopover from './ConnectOutputPopover.vue' import ConnectOutputPopover from './ConnectOutputPopover.vue'
import StepBadge from './StepBadge.vue' import StepBadge from './StepBadge.vue'
import StepLabel from './StepLabel.vue' import StepLabel from './StepLabel.vue'
import type { BuilderToolbarStep } from './types' import type { BuilderToolbarStep } from './types'
import { storeToRefs } from 'pinia'
const { t } = useI18n() const { t } = useI18n()
const appModeStore = useAppModeStore() const { mode, setMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
const { saving, setSaving } = useBuilderSave()
useEventListener(document, 'keydown', (e: KeyboardEvent) => { const activeStep = computed(() => (saving.value ? 'save' : mode.value))
if (e.key !== 'Escape') return
e.preventDefault()
e.stopPropagation()
void appModeStore.exitBuilder()
})
const activeStep = computed(() =>
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
)
const stepClasses = 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' '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 { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore' import { useAppModeStore } from '@/stores/appModeStore'
import { useDialogStore } from '@/stores/dialogStore' import { useDialogStore } from '@/stores/dialogStore'
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue' import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue' import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
import { whenever } from '@vueuse/core'
const SAVE_DIALOG_KEY = 'builder-save' const SAVE_DIALOG_KEY = 'builder-save'
const SUCCESS_DIALOG_KEY = 'builder-save-success' const SUCCESS_DIALOG_KEY = 'builder-save-success'
export function useBuilderSave() { export function useBuilderSave() {
const appModeStore = useAppModeStore() const { setMode } = useAppMode()
const { toastErrorHandler } = useErrorHandling()
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService() const workflowService = useWorkflowService()
const dialogService = useDialogService() const dialogService = useDialogService()
const appModeStore = useAppModeStore()
const dialogStore = useDialogStore() const dialogStore = useDialogStore()
watch( const saving = ref(false)
() => appModeStore.isBuilderSaving,
(saving) => { whenever(saving, onBuilderSave)
if (saving) void onBuilderSave()
} function setSaving(value: boolean) {
) saving.value = value
}
async function onBuilderSave() { async function onBuilderSave() {
const workflow = workflowStore.activeWorkflow const workflow = workflowStore.activeWorkflow
@@ -33,13 +39,14 @@ export function useBuilderSave() {
return 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 { try {
workflow.changeTracker?.checkState() appModeStore.flushSelections()
appModeStore.saveSelectedToWorkflow()
await workflowService.saveWorkflow(workflow) await workflowService.saveWorkflow(workflow)
showSuccessDialog(workflow.filename, appModeStore.isAppMode) showSuccessDialog(workflow.filename, workflow.initialMode === 'app')
} catch { } catch (e) {
toastErrorHandler(e)
resetSaving() resetSaving()
} }
return return
@@ -73,17 +80,19 @@ export function useBuilderSave() {
const workflow = workflowStore.activeWorkflow const workflow = workflowStore.activeWorkflow
if (!workflow) return if (!workflow) return
appModeStore.saveSelectedToWorkflow() appModeStore.flushSelections()
const mode = openAsApp ? 'app' : 'graph'
const saved = await workflowService.saveWorkflowAs(workflow, { const saved = await workflowService.saveWorkflowAs(workflow, {
filename, filename,
openAsApp initialMode: mode
}) })
if (!saved) return if (!saved) return
closeSaveDialog() closeSaveDialog()
showSuccessDialog(filename, openAsApp) showSuccessDialog(filename, openAsApp)
} catch { } catch (e) {
toastErrorHandler(e)
closeSaveDialog() closeSaveDialog()
resetSaving() resetSaving()
} }
@@ -97,7 +106,7 @@ export function useBuilderSave() {
workflowName, workflowName,
savedAsApp, savedAsApp,
onViewApp: () => { onViewApp: () => {
appModeStore.setMode('app') setMode('app')
closeSuccessDialog() closeSuccessDialog()
}, },
onClose: closeSuccessDialog onClose: closeSuccessDialog
@@ -118,6 +127,8 @@ export function useBuilderSave() {
} }
function resetSaving() { function resetSaving() {
appModeStore.setBuilderSaving(false) saving.value = false
} }
return { saving, setSaving }
} }

View File

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

View File

@@ -3,7 +3,7 @@
ref="containerRef" ref="containerRef"
:class=" :class="
cn( 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 props.class
) )
" "

View File

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

View File

@@ -25,15 +25,19 @@ whenever(feedbackRef, () => {
:href="`https://form.typeform.com/to/${dataTfWidget}`" :href="`https://form.typeform.com/to/${dataTfWidget}`"
target="_blank" target="_blank"
variant="inverted" 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" v-bind="$attrs"
> >
<i class="icon-[lucide--circle-question-mark] size-6" /> <i class="icon-[lucide--circle-help] size-4" />
</Button> </Button>
<Popover v-else> <Popover v-else>
<template #button> <template #button>
<Button variant="inverted" class="rounded-full size-12" v-bind="$attrs"> <Button
<i class="icon-[lucide--circle-question-mark] size-6" /> 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> </Button>
</template> </template>
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget /> <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' typeof metadata?.source === 'string' ? metadata.source : 'keybind'
const newMode = !canvasStore.linearMode const newMode = !canvasStore.linearMode
if (newMode) useTelemetry()?.trackEnterLinear({ source }) if (newMode) useTelemetry()?.trackEnterLinear({ source })
app.rootGraph.extra.linearMode = newMode
workflowStore.activeWorkflow?.changeTracker?.checkState()
canvasStore.linearMode = newMode canvasStore.linearMode = newMode
} }
} }

View File

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

View File

@@ -2,12 +2,58 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia' import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest' 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 { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore' import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService' import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { app } from '@/scripts/app' 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(() => ({ const { mockShowMissingNodes, mockShowMissingModels } = vi.hoisted(() => ({
mockShowMissingNodes: vi.fn(), 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'] = { const MISSING_MODELS: PendingWarnings['missingModels'] = {
missingModels: [ missingModels: [
{ name: 'model.safetensors', url: '', directory: 'checkpoints' } { name: 'model.safetensors', url: '', directory: 'checkpoints' }
@@ -83,7 +137,7 @@ function createWorkflow(
warnings: PendingWarnings | null = null, warnings: PendingWarnings | null = null,
options: { loadable?: boolean; path?: string } = {} options: { loadable?: boolean; path?: string } = {}
): ComfyWorkflow { ): ComfyWorkflow {
return { const wf = {
pendingWarnings: warnings, pendingWarnings: warnings,
...(options.loadable && { ...(options.loadable && {
path: options.path ?? 'workflows/test.json', path: options.path ?? 'workflows/test.json',
@@ -91,7 +145,8 @@ function createWorkflow(
activeState: { nodes: [], links: [] }, activeState: { nodes: [], links: [] },
changeTracker: { reset: vi.fn(), restore: vi.fn() } changeTracker: { reset: vi.fn(), restore: vi.fn() }
}) })
} as Partial<ComfyWorkflow> as ComfyWorkflow } as Partial<ComfyWorkflow>
return wf as ComfyWorkflow
} }
function enableWarningSettings() { function enableWarningSettings() {
@@ -180,12 +235,7 @@ describe('useWorkflowService', () => {
workflowStore = useWorkflowStore() workflowStore = useWorkflowStore()
vi.mocked(app.loadGraphData).mockImplementation( vi.mocked(app.loadGraphData).mockImplementation(
async (_data, _clean, _restore, wf) => { async (_data, _clean, _restore, wf) => {
;( workflowStore.activeWorkflow = wf as LoadedComfyWorkflow
workflowStore as Partial<Record<string, unknown>> as Record<
string,
unknown
>
).activeWorkflow = wf
} }
) )
}) })
@@ -256,4 +306,231 @@ describe('useWorkflowService', () => {
expect(mockShowMissingNodes).toHaveBeenCalledTimes(1) 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 { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore' import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore' import { useWorkflowDraftStore } from '@/platform/workflow/persistence/stores/workflowDraftStore'
import { syncLinearMode } from '@/platform/workflow/management/stores/comfyWorkflow'
import { import {
ComfyWorkflow, ComfyWorkflow,
useWorkflowStore useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore' } from '@/platform/workflow/management/stores/workflowStore'
import { useTelemetry } from '@/platform/telemetry' import { useTelemetry } from '@/platform/telemetry'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema' import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail' import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph' import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
import { useMissingModelsDialog } from '@/composables/useMissingModelsDialog' import { useMissingModelsDialog } from '@/composables/useMissingModelsDialog'
import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog' import { useMissingNodesDialog } from '@/composables/useMissingNodesDialog'
import { useDialogService } from '@/services/dialogService' import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import type { AppMode } from '@/composables/useAppMode'
import { useDomWidgetStore } from '@/stores/domWidgetStore' import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore' import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWorkspaceStore } from '@/stores/workspaceStore' import { useWorkspaceStore } from '@/stores/workspaceStore'
import { appendJsonExt } from '@/utils/formatUtil' import { appendJsonExt } from '@/utils/formatUtil'
function linearModeToAppMode(linearMode: unknown): AppMode | null {
if (typeof linearMode !== 'boolean') return null
return linearMode ? 'app' : 'graph'
}
export const useWorkflowService = () => { export const useWorkflowService = () => {
const settingStore = useSettingStore() const settingStore = useSettingStore()
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
@@ -93,24 +100,20 @@ export const useWorkflowService = () => {
* Save a workflow as a new file * Save a workflow as a new file
* @param workflow The workflow to save * @param workflow The workflow to save
* @param options.filename Pre-supplied filename (skips the prompt dialog) * @param options.filename Pre-supplied filename (skips the prompt dialog)
* @param options.openAsApp If set, updates linearMode extra before saving
*/ */
const saveWorkflowAs = async ( const saveWorkflowAs = async (
workflow: ComfyWorkflow, workflow: ComfyWorkflow,
options: { filename?: string; openAsApp?: boolean } = {} options: { filename?: string; initialMode?: AppMode } = {}
): Promise<boolean> => { ): Promise<boolean> => {
const newFilename = options.filename ?? (await workflow.promptSave()) const newFilename = options.filename ?? (await workflow.promptSave())
if (!newFilename) return false 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 newPath = workflow.directory + '/' + appendJsonExt(newFilename)
const existingWorkflow = workflowStore.getWorkflowByPath(newPath) const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
const isSelfOverwrite =
existingWorkflow?.path === workflow.path && !existingWorkflow?.isTemporary
if (existingWorkflow && !existingWorkflow.isTemporary) { if (existingWorkflow && !existingWorkflow.isTemporary) {
const res = await dialogService.confirm({ const res = await dialogService.confirm({
title: t('sideToolbar.workflowTab.confirmOverwriteTitle'), title: t('sideToolbar.workflowTab.confirmOverwriteTitle'),
@@ -121,15 +124,20 @@ export const useWorkflowService = () => {
if (res !== true) return false if (res !== true) return false
if (existingWorkflow.path === workflow.path) { if (!isSelfOverwrite) {
await saveWorkflow(workflow) const deleted = await deleteWorkflow(existingWorkflow, true)
return 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 renameWorkflow(workflow, newPath)
await workflowStore.saveWorkflow(workflow) await workflowStore.saveWorkflow(workflow)
} else { } else {
@@ -148,6 +156,8 @@ export const useWorkflowService = () => {
if (workflow.isTemporary) { if (workflow.isTemporary) {
await saveWorkflowAs(workflow) await saveWorkflowAs(workflow)
} else { } else {
syncLinearMode(workflow, [app.rootGraph], { flushLinearData: true })
workflow.changeTracker?.checkState()
await workflowStore.saveWorkflow(workflow) await workflowStore.saveWorkflow(workflow)
} }
} }
@@ -358,13 +368,17 @@ export const useWorkflowService = () => {
workflowData: ComfyWorkflowJSON workflowData: ComfyWorkflowJSON
) => { ) => {
const workflowStore = useWorkspaceStore().workflow const workflowStore = useWorkspaceStore().workflow
if ( const { isAppMode } = useAppMode()
workflowData.extra?.linearMode !== undefined || const wasAppMode = isAppMode.value
!workflowData.nodes.length
) { // Determine the initial app mode for fresh loads from serialized state.
if (workflowData.extra?.linearMode && !useCanvasStore().linearMode) // 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' }) useTelemetry()?.trackEnterLinear({ source: 'workflow' })
useCanvasStore().linearMode = !!workflowData.extra?.linearMode }
} }
if (value === null || typeof value === 'string') { if (value === null || typeof value === 'string') {
@@ -381,26 +395,39 @@ export const useWorkflowService = () => {
if (existingWorkflow?.isPersisted && !existingWorkflow.isLoaded) { if (existingWorkflow?.isPersisted && !existingWorkflow.isLoaded) {
const loadedWorkflow = const loadedWorkflow =
await workflowStore.openWorkflow(existingWorkflow) 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.reset(workflowData)
loadedWorkflow.changeTracker.restore() loadedWorkflow.changeTracker.restore()
return return
} }
} }
if (useCanvasStore().linearMode) {
app.rootGraph.extra ??= {}
app.rootGraph.extra.linearMode = true
}
const tempWorkflow = workflowStore.createNewTemporary( const tempWorkflow = workflowStore.createNewTemporary(
path ? appendJsonExt(path) : undefined, path ? appendJsonExt(path) : undefined,
workflowData workflowData
) )
tempWorkflow.initialMode = freshLoadMode
trackIfEnteringApp(tempWorkflow)
syncLinearMode(tempWorkflow, [workflowData, app.rootGraph])
await workflowStore.openWorkflow(tempWorkflow) await workflowStore.openWorkflow(tempWorkflow)
return return
} }
const loadedWorkflow = await workflowStore.openWorkflow(value) 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.reset(workflowData)
loadedWorkflow.changeTracker.restore() loadedWorkflow.changeTracker.restore()
} }

View File

@@ -2,6 +2,8 @@ import { markRaw } from 'vue'
import { t } from '@/i18n' import { t } from '@/i18n'
import type { ChangeTracker } from '@/scripts/changeTracker' 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 { UserFile } from '@/stores/userFileStore'
import type { import type {
ComfyWorkflowJSON, ComfyWorkflowJSON,
@@ -9,6 +11,11 @@ import type {
} from '@/platform/workflow/validation/schemas/workflowSchema' } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { MissingNodeType } from '@/types/comfy' import type { MissingNodeType } from '@/types/comfy'
export interface LinearData {
inputs: [NodeId, string][]
outputs: NodeId[]
}
export interface PendingWarnings { export interface PendingWarnings {
missingNodeTypes?: MissingNodeType[] missingNodeTypes?: MissingNodeType[]
missingModels?: { 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 { export class ComfyWorkflow extends UserFile {
static readonly basePath: string = 'workflows/' static readonly basePath: string = 'workflows/'
readonly tintCanvasBg?: string readonly tintCanvasBg?: string
@@ -33,6 +65,23 @@ export class ComfyWorkflow extends UserFile {
* Warnings deferred from load time, shown when the workflow is first focused. * Warnings deferred from load time, shown when the workflow is first focused.
*/ */
pendingWarnings: PendingWarnings | null = null 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. * @param options The path, modified, and size of the workflow.
@@ -129,6 +178,7 @@ export class ComfyWorkflow extends UserFile {
override unload(): void { override unload(): void {
this.changeTracker = null this.changeTracker = null
this.activeMode = null
super.unload() super.unload()
} }

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueSettingsStore } from '@/stores/queueStore' import { useQueueSettingsStore } from '@/stores/queueStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget' import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore' import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n() const { t } = useI18n()
const commandStore = useCommandStore() const commandStore = useCommandStore()
@@ -35,7 +36,9 @@ const { batchCount } = storeToRefs(useQueueSettingsStore())
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { isActiveSubscription } = useBillingContext() const { isActiveSubscription } = useBillingContext()
const workflowStore = useWorkflowStore() const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore() const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const props = defineProps<{ const props = defineProps<{
toastTo?: string | HTMLElement toastTo?: string | HTMLElement
@@ -168,7 +171,7 @@ defineExpose({ runButtonClick })
</script> </script>
<template> <template>
<div <div
v-if="!appModeStore.isBuilderMode && appModeStore.hasOutputs" v-if="!isBuilderMode && hasOutputs"
class="flex flex-col min-w-80 md:h-full" class="flex flex-col min-w-80 md:h-full"
> >
<section <section
@@ -299,7 +302,7 @@ defineExpose({ runButtonClick })
<Button <Button
v-else v-else
variant="primary" variant="primary"
class="w-full mt-4" class="w-full mt-4 text-sm"
size="lg" size="lg"
@click="runButtonClick" @click="runButtonClick"
> >
@@ -315,14 +318,18 @@ defineExpose({ runButtonClick })
:to="toastTo" :to="toastTo"
> >
<div <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 <i
v-if="jobFinishedQueue" 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" /> <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> </div>
</Teleport> </Teleport>
</template> </template>

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import {
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer' import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import OutputHistoryActiveQueueItem from '@/renderer/extensions/linearMode/OutputHistoryActiveQueueItem.vue'
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue' import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore' import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
import type { import type {
@@ -275,7 +276,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
orientation="horizontal" orientation="horizontal"
selection-behavior="replace" selection-behavior="replace"
by="id" by="id"
class="min-w-0" class="min-w-0 px-4 pb-4"
@update:model-value="onSelectionChange" @update:model-value="onSelectionChange"
> >
<ListboxContent as-child> <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 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 v-if="queueCount > 0" class="shrink-0 flex items-center gap-0.5">
<div <OutputHistoryActiveQueueItem :queue-count="queueCount" />
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>
<div <div
v-if="hasActiveContent || visibleHistory.length > 0" v-if="hasActiveContent || visibleHistory.length > 0"
class="border-l border-border-default h-12 shrink-0 mx-4" 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() apiTarget: new EventTarget()
})) }))
vi.mock('@/stores/appModeStore', () => ({ vi.mock('@/composables/useAppMode', () => ({
useAppModeStore: () => ({ useAppMode: () => ({
get isAppMode() { isAppMode: isAppModeRef
return isAppModeRef.value
}
}) })
})) }))

View File

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

View File

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

View File

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

View File

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

View File

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