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>
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 93 KiB |
@@ -124,7 +124,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
@@ -145,12 +145,12 @@ const unifiedWidth = computed(() =>
|
||||
|
||||
const { focusMode } = storeToRefs(workspaceStore)
|
||||
|
||||
const appModeStore = useAppModeStore()
|
||||
const { mode } = useAppMode()
|
||||
const { activeSidebarTabId, activeSidebarTab } = storeToRefs(sidebarTabStore)
|
||||
const { bottomPanelVisible } = storeToRefs(useBottomPanelStore())
|
||||
const { isOpen: rightSidePanelVisible } = storeToRefs(rightSidePanelStore)
|
||||
const showOffsideSplitter = computed(
|
||||
() => rightSidePanelVisible.value || appModeStore.mode === 'builder:select'
|
||||
() => rightSidePanelVisible.value || mode.value === 'builder:select'
|
||||
)
|
||||
|
||||
const sidebarPanelVisible = computed(() => activeSidebarTab.value !== null)
|
||||
|
||||
@@ -8,12 +8,12 @@ import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemp
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { enableAppBuilder, setMode } = useAppMode()
|
||||
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
|
||||
|
||||
const isAssetsActive = computed(
|
||||
@@ -24,7 +24,7 @@ const isWorkflowsActive = computed(
|
||||
)
|
||||
|
||||
function enterBuilderMode() {
|
||||
appModeStore.setMode('builder:select')
|
||||
setMode('builder:select')
|
||||
}
|
||||
|
||||
function openAssets() {
|
||||
@@ -61,7 +61,7 @@ function openTemplates() {
|
||||
</WorkflowActionsDropdown>
|
||||
|
||||
<Button
|
||||
v-if="appModeStore.enableAppBuilder"
|
||||
v-if="enableAppBuilder"
|
||||
v-tooltip.right="{
|
||||
value: t('linearMode.appModeToolbar.appBuilder'),
|
||||
...tooltipOptions
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
)
|
||||
"
|
||||
:aria-current="activeStep === step.id ? 'step' : undefined"
|
||||
@click="appModeStore.setMode(step.id)"
|
||||
@click="setMode(step.id)"
|
||||
>
|
||||
<StepBadge :step :index :model-value="activeStep" />
|
||||
<StepLabel :step />
|
||||
@@ -31,9 +31,9 @@
|
||||
|
||||
<!-- Save -->
|
||||
<ConnectOutputPopover
|
||||
v-if="!appModeStore.hasOutputs"
|
||||
v-if="!hasOutputs"
|
||||
:is-select-active="activeStep === 'builder:select'"
|
||||
@switch="appModeStore.setMode('builder:select')"
|
||||
@switch="setMode('builder:select')"
|
||||
>
|
||||
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
|
||||
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
|
||||
@@ -50,7 +50,7 @@
|
||||
: 'hover:bg-secondary-background bg-transparent'
|
||||
)
|
||||
"
|
||||
@click="appModeStore.setBuilderSaving(true)"
|
||||
@click="setSaving(true)"
|
||||
>
|
||||
<StepBadge :step="saveStep" :index="2" :model-value="activeStep" />
|
||||
<StepLabel :step="saveStep" />
|
||||
@@ -62,31 +62,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import type { AppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { AppMode } from '@/stores/appModeStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import { useBuilderSave } from './useBuilderSave'
|
||||
import ConnectOutputPopover from './ConnectOutputPopover.vue'
|
||||
import StepBadge from './StepBadge.vue'
|
||||
import StepLabel from './StepLabel.vue'
|
||||
import type { BuilderToolbarStep } from './types'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { mode, setMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
const { saving, setSaving } = useBuilderSave()
|
||||
|
||||
useEventListener(document, 'keydown', (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
void appModeStore.exitBuilder()
|
||||
})
|
||||
|
||||
const activeStep = computed(() =>
|
||||
appModeStore.isBuilderSaving ? 'save' : appModeStore.mode
|
||||
)
|
||||
const activeStep = computed(() => (saving.value ? 'save' : mode.value))
|
||||
|
||||
const stepClasses =
|
||||
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
|
||||
|
||||
@@ -1,30 +1,36 @@
|
||||
import { watch } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import BuilderSaveDialogContent from './BuilderSaveDialogContent.vue'
|
||||
import BuilderSaveSuccessDialogContent from './BuilderSaveSuccessDialogContent.vue'
|
||||
import { whenever } from '@vueuse/core'
|
||||
|
||||
const SAVE_DIALOG_KEY = 'builder-save'
|
||||
const SUCCESS_DIALOG_KEY = 'builder-save-success'
|
||||
|
||||
export function useBuilderSave() {
|
||||
const appModeStore = useAppModeStore()
|
||||
const { setMode } = useAppMode()
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const workflowService = useWorkflowService()
|
||||
const dialogService = useDialogService()
|
||||
const appModeStore = useAppModeStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
watch(
|
||||
() => appModeStore.isBuilderSaving,
|
||||
(saving) => {
|
||||
if (saving) void onBuilderSave()
|
||||
}
|
||||
)
|
||||
const saving = ref(false)
|
||||
|
||||
whenever(saving, onBuilderSave)
|
||||
|
||||
function setSaving(value: boolean) {
|
||||
saving.value = value
|
||||
}
|
||||
|
||||
async function onBuilderSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
@@ -33,13 +39,14 @@ export function useBuilderSave() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!workflow.isTemporary && workflow.activeState.extra?.linearMode) {
|
||||
if (!workflow.isTemporary && workflow.initialMode != null) {
|
||||
// Re-save with the previously chosen mode — no dialog needed.
|
||||
try {
|
||||
workflow.changeTracker?.checkState()
|
||||
appModeStore.saveSelectedToWorkflow()
|
||||
appModeStore.flushSelections()
|
||||
await workflowService.saveWorkflow(workflow)
|
||||
showSuccessDialog(workflow.filename, appModeStore.isAppMode)
|
||||
} catch {
|
||||
showSuccessDialog(workflow.filename, workflow.initialMode === 'app')
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
resetSaving()
|
||||
}
|
||||
return
|
||||
@@ -73,17 +80,19 @@ export function useBuilderSave() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (!workflow) return
|
||||
|
||||
appModeStore.saveSelectedToWorkflow()
|
||||
appModeStore.flushSelections()
|
||||
const mode = openAsApp ? 'app' : 'graph'
|
||||
const saved = await workflowService.saveWorkflowAs(workflow, {
|
||||
filename,
|
||||
openAsApp
|
||||
initialMode: mode
|
||||
})
|
||||
|
||||
if (!saved) return
|
||||
|
||||
closeSaveDialog()
|
||||
showSuccessDialog(filename, openAsApp)
|
||||
} catch {
|
||||
} catch (e) {
|
||||
toastErrorHandler(e)
|
||||
closeSaveDialog()
|
||||
resetSaving()
|
||||
}
|
||||
@@ -97,7 +106,7 @@ export function useBuilderSave() {
|
||||
workflowName,
|
||||
savedAsApp,
|
||||
onViewApp: () => {
|
||||
appModeStore.setMode('app')
|
||||
setMode('app')
|
||||
closeSuccessDialog()
|
||||
},
|
||||
onClose: closeSuccessDialog
|
||||
@@ -118,6 +127,8 @@ export function useBuilderSave() {
|
||||
}
|
||||
|
||||
function resetSaving() {
|
||||
appModeStore.setBuilderSaving(false)
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
return { saving, setSaving }
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showUI && !appModeStore.isBuilderMode" #side-toolbar>
|
||||
<template v-if="showUI && !isBuilderMode" #side-toolbar>
|
||||
<SideToolbar />
|
||||
</template>
|
||||
<template v-if="showUI" #side-bar-panel>
|
||||
@@ -31,27 +31,24 @@
|
||||
<ExtensionSlot v-if="activeSidebarTab" :extension="activeSidebarTab" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="showUI && !appModeStore.isBuilderMode" #topmenu>
|
||||
<template v-if="showUI && !isBuilderMode" #topmenu>
|
||||
<TopMenuSection />
|
||||
</template>
|
||||
<template v-if="showUI" #bottom-panel>
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template v-if="showUI" #right-side-panel>
|
||||
<AppBuilder v-if="appModeStore.mode === 'builder:select'" />
|
||||
<NodePropertiesPanel v-else-if="!appModeStore.isBuilderMode" />
|
||||
<AppBuilder v-if="mode === 'builder:select'" />
|
||||
<NodePropertiesPanel v-else-if="!isBuilderMode" />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<GraphCanvasMenu
|
||||
v-if="canvasMenuEnabled && !appModeStore.isBuilderMode"
|
||||
v-if="canvasMenuEnabled && !isBuilderMode"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
<MiniMap
|
||||
v-if="
|
||||
comfyAppReady &&
|
||||
minimapEnabled &&
|
||||
betaMenuEnabled &&
|
||||
!appModeStore.isBuilderMode
|
||||
comfyAppReady && minimapEnabled && betaMenuEnabled && !isBuilderMode
|
||||
"
|
||||
class="pointer-events-auto"
|
||||
/>
|
||||
@@ -127,10 +124,10 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { isMiddlePointerInput } from '@/base/pointerUtils'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import AppBuilder from '@/components/builder/AppBuilder.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
@@ -184,7 +181,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { isNativeWindow } from '@/utils/envUtil'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
@@ -205,7 +202,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
|
||||
const settingStore = useSettingStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { mode, isBuilderMode } = useAppMode()
|
||||
const canvasStore = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
ref="containerRef"
|
||||
:class="
|
||||
cn(
|
||||
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col',
|
||||
'comfy-vue-side-bar-container group/sidebar-tab flex h-full flex-col w-full',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
@click="handleClick"
|
||||
>
|
||||
<i
|
||||
v-if="workflowOption.workflow.activeState?.extra?.linearMode"
|
||||
v-if="workflowOption.workflow.initialMode === 'app'"
|
||||
class="icon-[lucide--panels-top-left] bg-primary-background"
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -25,15 +25,19 @@ whenever(feedbackRef, () => {
|
||||
:href="`https://form.typeform.com/to/${dataTfWidget}`"
|
||||
target="_blank"
|
||||
variant="inverted"
|
||||
class="rounded-full size-12"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-question-mark] size-6" />
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
<Popover v-else>
|
||||
<template #button>
|
||||
<Button variant="inverted" class="rounded-full size-12" v-bind="$attrs">
|
||||
<i class="icon-[lucide--circle-question-mark] size-6" />
|
||||
<Button
|
||||
variant="inverted"
|
||||
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<i class="icon-[lucide--circle-help] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<div ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
|
||||
|
||||
43
src/composables/useAppMode.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -1338,8 +1338,6 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
typeof metadata?.source === 'string' ? metadata.source : 'keybind'
|
||||
const newMode = !canvasStore.linearMode
|
||||
if (newMode) useTelemetry()?.trackEnterLinear({ source })
|
||||
app.rootGraph.extra.linearMode = newMode
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
canvasStore.linearMode = newMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1159,8 +1159,8 @@
|
||||
"queue": {
|
||||
"initializingAlmostReady": "Initializing - Almost ready",
|
||||
"inQueue": "In queue...",
|
||||
"jobAddedToQueue": "Job added to queue",
|
||||
"jobQueueing": "Job queueing",
|
||||
"jobAddedToQueue": "Job queued",
|
||||
"jobQueueing": "Job queuing",
|
||||
"completedIn": "Finished in {duration}",
|
||||
"jobMenu": {
|
||||
"openAsWorkflowNewTab": "Open as workflow in new tab",
|
||||
@@ -2996,7 +2996,8 @@
|
||||
},
|
||||
"linearMode": {
|
||||
"linearMode": "App Mode",
|
||||
"beta": "App Mode in Beta - Feedback",
|
||||
"beta": "App mode in beta",
|
||||
"giveFeedback": "Give feedback",
|
||||
"graphMode": "Graph Mode",
|
||||
"dragAndDropImage": "Click to browse or drag an image",
|
||||
"runCount": "Number of runs",
|
||||
@@ -3034,11 +3035,15 @@
|
||||
"promptAddInputs": "Click on node parameters to add them here as inputs",
|
||||
"noInputs": "No inputs added yet",
|
||||
"inputsDesc": "Users will interact and adjust these to generate their outputs.",
|
||||
"inputsExample": "Examples: “Load image”, “Text prompt”, “Steps”",
|
||||
"inputsExample": "Examples: \u201cLoad image\u201d, \u201cText prompt\u201d, \u201cSteps\u201d",
|
||||
"promptAddOutputs": "Click on output nodes to add them here. These will be the generated results.",
|
||||
"noOutputs": "No output nodes added yet",
|
||||
"outputsDesc": "Connect at least one output node so users can see results after running.",
|
||||
"outputsExample": "Examples: “Save Image” or “Save Video”"
|
||||
"outputsExample": "Examples: \u201cSave Image\u201d or \u201cSave Video\u201d"
|
||||
},
|
||||
"queue": {
|
||||
"clickToClear": "Click to clear queue",
|
||||
"clear": "Clear queue"
|
||||
}
|
||||
},
|
||||
"missingNodes": {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineStore } from 'pinia'
|
||||
import { computed, markRaw, ref, shallowRef } from 'vue'
|
||||
import type { Raw } from 'vue'
|
||||
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
|
||||
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
|
||||
import type {
|
||||
@@ -44,10 +44,11 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
// Reactive scale percentage that syncs with app.canvas.ds.scale
|
||||
const appScalePercentage = ref(100)
|
||||
|
||||
const { isAppMode, setMode } = useAppMode()
|
||||
const linearMode = computed({
|
||||
get: () => useAppModeStore().isAppMode,
|
||||
get: () => isAppMode.value,
|
||||
set: (val: boolean) => {
|
||||
useAppModeStore().setMode(val ? 'app' : 'graph')
|
||||
setMode(val ? 'app' : 'graph')
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { setMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="appModeStore.hasOutputs"
|
||||
v-if="hasOutputs"
|
||||
role="article"
|
||||
data-testid="arrange-preview"
|
||||
class="flex flex-col items-center justify-center h-full w-3/4 gap-6 p-8 mx-auto"
|
||||
@@ -47,11 +50,7 @@ const appModeStore = useAppModeStore()
|
||||
<p class="mt-0 p-0">{{ t('linearMode.arrange.outputExamples') }}</p>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
@click="appModeStore.setMode('builder:select')"
|
||||
>
|
||||
<Button variant="primary" size="lg" @click="setMode('builder:select')">
|
||||
{{ t('linearMode.arrange.switchToSelectButton') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useQueueSettingsStore } from '@/stores/queueStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
@@ -35,7 +36,9 @@ const { batchCount } = storeToRefs(useQueueSettingsStore())
|
||||
const settingStore = useSettingStore()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs } = storeToRefs(appModeStore)
|
||||
|
||||
const props = defineProps<{
|
||||
toastTo?: string | HTMLElement
|
||||
@@ -168,7 +171,7 @@ defineExpose({ runButtonClick })
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
v-if="!appModeStore.isBuilderMode && appModeStore.hasOutputs"
|
||||
v-if="!isBuilderMode && hasOutputs"
|
||||
class="flex flex-col min-w-80 md:h-full"
|
||||
>
|
||||
<section
|
||||
@@ -299,7 +302,7 @@ defineExpose({ runButtonClick })
|
||||
<Button
|
||||
v-else
|
||||
variant="primary"
|
||||
class="w-full mt-4"
|
||||
class="w-full mt-4 text-sm"
|
||||
size="lg"
|
||||
@click="runButtonClick"
|
||||
>
|
||||
@@ -315,14 +318,18 @@ defineExpose({ runButtonClick })
|
||||
:to="toastTo"
|
||||
>
|
||||
<div
|
||||
class="bg-base-foreground text-base-background rounded-sm flex h-8 p-1 pr-2 gap-2 items-center"
|
||||
class="bg-secondary-background text-base-foreground rounded-lg flex h-8 p-1 pr-2 gap-2 items-center"
|
||||
>
|
||||
<i
|
||||
v-if="jobFinishedQueue"
|
||||
class="icon-[lucide--check] size-5 bg-success-background"
|
||||
class="icon-[lucide--check] size-5 text-muted-foreground"
|
||||
/>
|
||||
<i v-else class="icon-[lucide--loader-circle] size-4 animate-spin" />
|
||||
<span v-text="t('queue.jobAddedToQueue')" />
|
||||
<span
|
||||
v-text="
|
||||
jobFinishedQueue ? t('queue.jobAddedToQueue') : t('queue.jobQueueing')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -27,13 +27,13 @@ import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
import { collectAllNodes } from '@/utils/graphTraversalUtil'
|
||||
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const mediaActions = useMediaAssetActions()
|
||||
const queueStore = useQueueStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { mode: appModeValue } = useAppMode()
|
||||
const { runButtonClick } = defineProps<{
|
||||
runButtonClick?: (e: Event) => void
|
||||
mobile?: boolean
|
||||
@@ -165,7 +165,7 @@ async function rerun(e: Event) {
|
||||
:model-url="selectedOutput!.url"
|
||||
/>
|
||||
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
|
||||
<LinearArrange v-else-if="appModeStore.mode === 'builder:arrange'" />
|
||||
<LinearArrange v-else-if="appModeValue === 'builder:arrange'" />
|
||||
<LinearWelcome v-else />
|
||||
<OutputHistory @update-selection="handleSelection" />
|
||||
<OutputHistory class="not-md:mx-40" @update-selection="handleSelection" />
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { setMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,7 +27,7 @@ const appModeStore = useAppModeStore()
|
||||
<p class="mt-0">{{ t('linearMode.welcome.controls') }}</p>
|
||||
<p class="mt-0">{{ t('linearMode.welcome.sharing') }}</p>
|
||||
</div>
|
||||
<div v-if="appModeStore.hasOutputs" class="flex flex-row gap-2 text-[14px]">
|
||||
<div v-if="hasOutputs" class="flex flex-row gap-2 text-[14px]">
|
||||
<p class="mt-0 text-base-foreground">
|
||||
<i18n-t keypath="linearMode.welcome.getStarted" tag="span">
|
||||
<template #runButton>
|
||||
@@ -38,18 +41,10 @@ const appModeStore = useAppModeStore()
|
||||
</p>
|
||||
</div>
|
||||
<div v-else class="flex flex-row gap-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="lg"
|
||||
@click="appModeStore.setMode('graph')"
|
||||
>
|
||||
<Button variant="textonly" size="lg" @click="setMode('graph')">
|
||||
{{ t('linearMode.welcome.backToWorkflow') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
@click="appModeStore.setMode('builder:select')"
|
||||
>
|
||||
<Button variant="primary" size="lg" @click="setMode('builder:select')">
|
||||
<i class="icon-[lucide--hammer]" />
|
||||
{{ t('linearMode.welcome.buildApp') }}
|
||||
<div
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
|
||||
import OutputHistoryActiveQueueItem from '@/renderer/extensions/linearMode/OutputHistoryActiveQueueItem.vue'
|
||||
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
|
||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
import type {
|
||||
@@ -275,7 +276,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
orientation="horizontal"
|
||||
selection-behavior="replace"
|
||||
by="id"
|
||||
class="min-w-0"
|
||||
class="min-w-0 px-4 pb-4"
|
||||
@update:model-value="onSelectionChange"
|
||||
>
|
||||
<ListboxContent as-child>
|
||||
@@ -287,23 +288,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
>
|
||||
<div class="flex items-center gap-0.5 mx-auto w-fit">
|
||||
<div v-if="queueCount > 0" class="shrink-0 flex items-center gap-0.5">
|
||||
<div
|
||||
class="shrink-0 p-1 border-2 border-transparent relative"
|
||||
data-testid="linear-job"
|
||||
>
|
||||
<div
|
||||
class="size-10 rounded-sm bg-secondary-background flex items-center justify-center"
|
||||
>
|
||||
<i
|
||||
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="queueCount > 1"
|
||||
class="absolute top-0 right-0 min-w-4 h-4 flex justify-center items-center rounded-full bg-primary-background text-text-primary text-xs"
|
||||
v-text="queueCount"
|
||||
/>
|
||||
</div>
|
||||
<OutputHistoryActiveQueueItem :queue-count="queueCount" />
|
||||
<div
|
||||
v-if="hasActiveContent || visibleHistory.length > 0"
|
||||
class="border-l border-border-default h-12 shrink-0 mx-4"
|
||||
|
||||
@@ -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>
|
||||
@@ -14,11 +14,9 @@ const { apiTarget } = vi.hoisted(() => ({
|
||||
apiTarget: new EventTarget()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => ({
|
||||
get isAppMode() {
|
||||
return isAppModeRef.value
|
||||
}
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({
|
||||
isAppMode: isAppModeRef
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeO
|
||||
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
|
||||
|
||||
export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
const appModeStore = useAppModeStore()
|
||||
const { isAppMode } = useAppMode()
|
||||
const executionStore = useExecutionStore()
|
||||
const jobPreviewStore = useJobPreviewStore()
|
||||
|
||||
@@ -220,7 +220,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
watch(
|
||||
() => executionStore.activeJobId,
|
||||
(jobId, oldJobId) => {
|
||||
if (!appModeStore.isAppMode) return
|
||||
if (!isAppMode.value) return
|
||||
if (oldJobId && oldJobId !== jobId) {
|
||||
onJobComplete(oldJobId)
|
||||
}
|
||||
@@ -233,7 +233,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
watch(
|
||||
() => jobPreviewStore.previewsByPromptId,
|
||||
(previews) => {
|
||||
if (!appModeStore.isAppMode) return
|
||||
if (!isAppMode.value) return
|
||||
const jobId = executionStore.activeJobId
|
||||
if (!jobId) return
|
||||
const url = previews[jobId]
|
||||
@@ -243,7 +243,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
)
|
||||
|
||||
watch(
|
||||
() => appModeStore.isAppMode,
|
||||
isAppMode,
|
||||
(active, wasActive) => {
|
||||
if (active) {
|
||||
api.addEventListener('executed', handleExecuted)
|
||||
|
||||
@@ -1,66 +1,67 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { reactive, readonly, computed, ref, watch } from 'vue'
|
||||
import { reactive, computed, watch } from 'vue'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { t } from '@/i18n'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LinearData } from '@/platform/workflow/management/stores/comfyWorkflow'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { app } from '@/scripts/app'
|
||||
|
||||
export type AppMode = 'graph' | 'app' | 'builder:select' | 'builder:arrange'
|
||||
|
||||
export const useAppModeStore = defineStore('appMode', () => {
|
||||
const { getCanvas } = useCanvasStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { mode, setMode, isBuilderMode } = useAppMode()
|
||||
|
||||
const selectedInputs = reactive<[NodeId, string][]>([])
|
||||
const selectedOutputs = reactive<NodeId[]>([])
|
||||
const mode = ref<AppMode>('graph')
|
||||
const builderSaving = ref(false)
|
||||
const hasOutputs = computed(() => !!selectedOutputs.length)
|
||||
const enableAppBuilder = ref(true)
|
||||
|
||||
const isBuilderMode = computed(
|
||||
() => mode.value === 'builder:select' || mode.value === 'builder:arrange'
|
||||
)
|
||||
const isAppMode = computed(
|
||||
() => mode.value === 'app' || mode.value === 'builder:arrange'
|
||||
)
|
||||
const isGraphMode = computed(
|
||||
() => mode.value === 'graph' || mode.value === 'builder:select'
|
||||
)
|
||||
const isBuilderSaving = computed(
|
||||
() => builderSaving.value && isBuilderMode.value
|
||||
)
|
||||
function loadSelections(data: Partial<LinearData> | undefined) {
|
||||
selectedInputs.splice(0, selectedInputs.length, ...(data?.inputs ?? []))
|
||||
selectedOutputs.splice(0, selectedOutputs.length, ...(data?.outputs ?? []))
|
||||
}
|
||||
|
||||
function resetSelectedToWorkflow() {
|
||||
const { activeWorkflow } = workflowStore
|
||||
if (!activeWorkflow) return
|
||||
|
||||
const { activeState } = activeWorkflow.changeTracker
|
||||
selectedInputs.splice(
|
||||
0,
|
||||
selectedInputs.length,
|
||||
...(activeState.extra?.linearData?.inputs ?? [])
|
||||
)
|
||||
selectedOutputs.splice(
|
||||
0,
|
||||
selectedOutputs.length,
|
||||
...(activeState.extra?.linearData?.outputs ?? [])
|
||||
)
|
||||
loadSelections(activeWorkflow.changeTracker?.activeState?.extra?.linearData)
|
||||
}
|
||||
function saveSelectedToWorkflow() {
|
||||
app.rootGraph.extra ??= {}
|
||||
app.rootGraph.extra.linearData = {
|
||||
inputs: [...selectedInputs],
|
||||
outputs: [...selectedOutputs]
|
||||
|
||||
function flushSelections() {
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (workflow) {
|
||||
workflow.dirtyLinearData = {
|
||||
inputs: [...selectedInputs],
|
||||
outputs: [...selectedOutputs]
|
||||
}
|
||||
}
|
||||
}
|
||||
whenever(() => workflowStore.activeWorkflow, resetSelectedToWorkflow, {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow,
|
||||
(newWorkflow, oldWorkflow) => {
|
||||
// Persist in-progress builder selections to the outgoing workflow
|
||||
if (oldWorkflow && isBuilderMode.value) {
|
||||
oldWorkflow.dirtyLinearData = {
|
||||
inputs: [...selectedInputs],
|
||||
outputs: [...selectedOutputs]
|
||||
}
|
||||
}
|
||||
// Load from incoming workflow: dirty state first, then persisted
|
||||
if (newWorkflow) {
|
||||
loadSelections(
|
||||
newWorkflow.dirtyLinearData ??
|
||||
newWorkflow.changeTracker?.activeState?.extra?.linearData
|
||||
)
|
||||
} else {
|
||||
loadSelections(undefined)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => mode.value === 'builder:select',
|
||||
@@ -76,30 +77,18 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
)
|
||||
return
|
||||
|
||||
const workflow = workflowStore.activeWorkflow
|
||||
if (workflow) workflow.dirtyLinearData = null
|
||||
resetSelectedToWorkflow()
|
||||
mode.value = 'graph'
|
||||
setMode('graph')
|
||||
}
|
||||
|
||||
return {
|
||||
mode: readonly(mode),
|
||||
enableAppBuilder: readonly(enableAppBuilder),
|
||||
exitBuilder,
|
||||
isBuilderMode,
|
||||
isAppMode,
|
||||
isGraphMode,
|
||||
isBuilderSaving,
|
||||
hasOutputs,
|
||||
flushSelections,
|
||||
resetSelectedToWorkflow,
|
||||
saveSelectedToWorkflow,
|
||||
selectedInputs,
|
||||
selectedOutputs,
|
||||
setBuilderSaving: (newBuilderSaving: boolean) => {
|
||||
if (!isBuilderMode.value) return
|
||||
builderSaving.value = newBuilderSaving
|
||||
},
|
||||
setMode: (newMode: AppMode) => {
|
||||
if (newMode === mode.value) return
|
||||
mode.value = newMode
|
||||
}
|
||||
selectedOutputs
|
||||
}
|
||||
})
|
||||
|
||||
@@ -226,7 +226,10 @@ export function createMockChangeTracker(
|
||||
undoQueue: [],
|
||||
redoQueue: [],
|
||||
changeCount: 0,
|
||||
checkState: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
store: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
return partial as Partial<ChangeTracker> as ChangeTracker
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<GraphCanvas @ready="onGraphReady" />
|
||||
</div>
|
||||
<LinearView v-if="linearMode" />
|
||||
<BuilderToolbar v-if="appModeStore.isBuilderMode" />
|
||||
<BuilderToolbar v-if="isBuilderMode" />
|
||||
</div>
|
||||
|
||||
<GlobalToast />
|
||||
@@ -70,7 +70,7 @@ import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { setupAutoQueueHandler } from '@/services/autoQueueService'
|
||||
import { useKeybindingService } from '@/platform/keybindings/keybindingService'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -88,12 +88,10 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import BuilderToolbar from '@/components/builder/BuilderToolbar.vue'
|
||||
import { useBuilderSave } from '@/components/builder/useBuilderSave'
|
||||
import LinearView from '@/views/LinearView.vue'
|
||||
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
|
||||
|
||||
setupAutoQueueHandler()
|
||||
useBuilderSave()
|
||||
useProgressFavicon()
|
||||
useBrowserTabTitle()
|
||||
|
||||
@@ -106,7 +104,7 @@ const queueStore = useQueueStore()
|
||||
const assetsStore = useAssetsStore()
|
||||
const versionCompatibilityStore = useVersionCompatibilityStore()
|
||||
const graphCanvasContainerRef = ref<HTMLDivElement | null>(null)
|
||||
const appModeStore = useAppModeStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const { linearMode } = storeToRefs(useCanvasStore())
|
||||
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { breakpointsTailwind, unrefElement, useBreakpoints } from '@vueuse/core'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
@@ -12,17 +13,20 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
|
||||
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
|
||||
import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBar.vue'
|
||||
import MobileMenu from '@/renderer/extensions/linearMode/MobileMenu.vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { isBuilderMode } = useAppMode()
|
||||
const { hasOutputs } = storeToRefs(useAppModeStore())
|
||||
|
||||
const mobileDisplay = useBreakpoints(breakpointsTailwind).smaller('md')
|
||||
|
||||
@@ -31,10 +35,14 @@ const sidebarOnLeft = computed(
|
||||
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
)
|
||||
const hasLeftPanel = computed(
|
||||
() => (sidebarOnLeft.value && activeTab.value) || !sidebarOnLeft.value
|
||||
() =>
|
||||
(sidebarOnLeft.value && activeTab.value) ||
|
||||
(!sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value)
|
||||
)
|
||||
const hasRightPanel = computed(
|
||||
() => sidebarOnLeft.value || (!sidebarOnLeft.value && activeTab.value)
|
||||
() =>
|
||||
(sidebarOnLeft.value && !isBuilderMode.value && hasOutputs.value) ||
|
||||
(!sidebarOnLeft.value && activeTab.value)
|
||||
)
|
||||
|
||||
const bottomLeftRef = useTemplateRef('bottomLeftRef')
|
||||
@@ -105,22 +113,27 @@ const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
|
||||
class="absolute top-0 left-0 w-[calc(100%+16px)] z-21"
|
||||
/>
|
||||
<LinearPreview :run-button-click="linearWorkflowRef?.runButtonClick" />
|
||||
<div class="absolute z-21 top-1 left-1">
|
||||
<AppModeToolbar v-if="!appModeStore.isBuilderMode" />
|
||||
<div class="absolute z-21 top-4 left-4">
|
||||
<AppModeToolbar v-if="!isBuilderMode" />
|
||||
</div>
|
||||
<div ref="bottomLeftRef" class="absolute z-20 bottom-4 left-4" />
|
||||
<div ref="bottomRightRef" class="absolute z-20 bottom-24 right-4" />
|
||||
<div ref="bottomLeftRef" class="absolute z-20 bottom-7 left-4" />
|
||||
<div ref="bottomRightRef" class="absolute z-20 bottom-7 right-4" />
|
||||
<div
|
||||
class="absolute z-20 bottom-4 right-4 text-base-foreground flex items-center gap-4"
|
||||
:class="
|
||||
cn(
|
||||
'absolute z-20 bottom-4 text-base-foreground flex items-center gap-2',
|
||||
sidebarOnLeft ? 'left-4' : 'right-4'
|
||||
)
|
||||
"
|
||||
>
|
||||
<TypeformPopoverButton
|
||||
data-tf-widget="gmVqFi8l"
|
||||
:align="
|
||||
settingStore.get('Comfy.Sidebar.Location') === 'left'
|
||||
? 'end'
|
||||
: 'start'
|
||||
"
|
||||
:align="sidebarOnLeft ? 'start' : 'end'"
|
||||
/>
|
||||
<div class="flex flex-col text-sm text-muted-foreground">
|
||||
<span>{{ t('linearMode.beta') }}</span>
|
||||
<span>{{ t('linearMode.giveFeedback') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel
|
||||
|
||||