mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-10 07:30:08 +00:00
feat/fix: App mode QA feedback 2 (#9511)
## Summary Additional fixes and updates based on testing ## Changes - **What**: - add warning to welcome screen & when sharing an app that has had all outputs removed - fix target workflow when changing mode via tab right click menu - change build app text to be conditional "edit" vs "build" depending on if an app is already defined - update empty apps sidebar tab button text to make it clearer - remove templates button from app mode (we will reintroduce this once we have app templates) - add "exit to graph" after applying default mode of node graph - update cancel button to remove item from queue if it hasn't started yet - improve scoping of jobs/outputs to the current workflow [not perfect but should be much improved] - close sidebar tabs on entering app mode - change tooltip to be under the workflow menu rather than covering the button ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9511-feat-fix-App-mode-QA-feedback-2-31b6d73d365081d59bbbc13111100d46) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -7,7 +7,6 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import {
|
||||
openShareDialog,
|
||||
@@ -44,10 +43,6 @@ function openAssets() {
|
||||
function showApps() {
|
||||
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
|
||||
}
|
||||
|
||||
function openTemplates() {
|
||||
useWorkflowTemplateSelectorDialog().show('sidebar')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -116,19 +111,6 @@ function openTemplates() {
|
||||
>
|
||||
<i class="icon-[lucide--panels-top-left] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: t('sideToolbar.templates'),
|
||||
...tooltipOptions
|
||||
}"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-label="t('sideToolbar.templates')"
|
||||
class="size-10"
|
||||
@click="openTemplates"
|
||||
>
|
||||
<i class="icon-[comfy--template] size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -217,7 +217,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
|
||||
}}
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<DraggableList
|
||||
v-if="isArrangeMode"
|
||||
v-slot="{ dragClass }"
|
||||
@@ -254,7 +254,6 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
:label="t('nodeHelpPage.inputs')"
|
||||
enable-empty-state
|
||||
:disabled="!appModeStore.selectedInputs.length"
|
||||
class="border-b border-border-subtle"
|
||||
:tooltip="`${t('linearMode.builder.inputsDesc')}\n${t('linearMode.builder.inputsExample')}`"
|
||||
:tooltip-delay="100"
|
||||
>
|
||||
@@ -266,14 +265,10 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
class="p-4 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddInputs')"
|
||||
/>
|
||||
<DraggableList
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedInputs"
|
||||
@@ -303,6 +298,12 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<div
|
||||
v-if="isSelectInputsMode && !appModeStore.selectedInputs.length"
|
||||
class="m-4 flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-primary-background bg-primary-background/20 text-center text-sm text-primary-background"
|
||||
>
|
||||
{{ t('linearMode.builder.inputPlaceholder') }}
|
||||
</div>
|
||||
<PropertiesAccordionItem
|
||||
v-if="isSelectOutputsMode"
|
||||
:label="t('nodeHelpPage.outputs')"
|
||||
@@ -319,14 +320,10 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
</template>
|
||||
<template #empty>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
class="p-4 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
class="w-full p-4 pt-2 text-muted-foreground"
|
||||
v-text="t('linearMode.builder.promptAddOutputs')"
|
||||
/>
|
||||
<DraggableList
|
||||
v-slot="{ dragClass }"
|
||||
v-model="appModeStore.selectedOutputs"
|
||||
@@ -349,6 +346,15 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
|
||||
/>
|
||||
</DraggableList>
|
||||
</PropertiesAccordionItem>
|
||||
<div
|
||||
v-if="isSelectOutputsMode && !appModeStore.selectedOutputs.length"
|
||||
class="m-4 flex flex-1 flex-col items-center justify-center gap-1 rounded-lg border-2 border-dashed border-warning-background bg-warning-background/20 text-center text-sm text-warning-background"
|
||||
>
|
||||
{{ t('linearMode.builder.outputPlaceholder') }}
|
||||
<span class="font-bold">
|
||||
{{ t('linearMode.builder.outputRequiredPlaceholder') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
<Button variant="muted-textonly" size="lg" @click="$emit('viewApp')">
|
||||
{{ $t('builderToolbar.viewApp') }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" @click="$emit('close')">
|
||||
{{ $t('g.close') }}
|
||||
<Button variant="secondary" size="lg" @click="$emit('exitToWorkflow')">
|
||||
{{ $t('builderToolbar.exitToWorkflow') }}
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
@@ -58,5 +58,6 @@ defineProps<{
|
||||
defineEmits<{
|
||||
viewApp: []
|
||||
close: []
|
||||
exitToWorkflow: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -58,6 +58,6 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {
|
||||
})
|
||||
|
||||
function onExitBuilder() {
|
||||
void appModeStore.exitBuilder()
|
||||
appModeStore.exitBuilder()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -94,7 +94,7 @@ function onEnterAppMode(close: () => void) {
|
||||
}
|
||||
|
||||
function onExitBuilder(close: () => void) {
|
||||
void appModeStore.exitBuilder()
|
||||
appModeStore.exitBuilder()
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -22,6 +22,10 @@ const mockApp = vi.hoisted(() => ({
|
||||
|
||||
const mockSetMode = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockAppModeStore = vi.hoisted(() => ({
|
||||
exitBuilder: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
useDialogService: () => mockDialogService
|
||||
}))
|
||||
@@ -42,6 +46,10 @@ vi.mock('@/composables/useAppMode', () => ({
|
||||
useAppMode: () => ({ setMode: mockSetMode })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: () => mockAppModeStore
|
||||
}))
|
||||
|
||||
vi.mock('./DefaultViewDialogContent.vue', () => ({
|
||||
default: { name: 'MockDefaultViewDialogContent' }
|
||||
}))
|
||||
@@ -208,6 +216,16 @@ describe('useAppSetDefaultView', () => {
|
||||
expect(mockSetMode).toHaveBeenCalledWith('app')
|
||||
})
|
||||
|
||||
it('onExitToWorkflow exits builder and closes dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
confirmCall.props.onExitToWorkflow()
|
||||
|
||||
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
|
||||
key: 'builder-default-view-applied'
|
||||
})
|
||||
expect(mockAppModeStore.exitBuilder).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('onClose closes confirmation dialog', () => {
|
||||
const confirmCall = applyAndGetConfirmDialog(true)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
import BuilderDefaultModeAppliedDialogContent from './BuilderDefaultModeAppliedDialogContent.vue'
|
||||
import DefaultViewDialogContent from './DefaultViewDialogContent.vue'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
|
||||
const DIALOG_KEY = 'builder-default-view'
|
||||
const APPLIED_DIALOG_KEY = 'builder-default-view-applied'
|
||||
@@ -16,6 +17,7 @@ export function useAppSetDefaultView() {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { setMode } = useAppMode()
|
||||
|
||||
const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY))
|
||||
@@ -54,6 +56,10 @@ export function useAppSetDefaultView() {
|
||||
closeAppliedDialog()
|
||||
setMode('app')
|
||||
},
|
||||
onExitToWorkflow: () => {
|
||||
closeAppliedDialog()
|
||||
appModeStore.exitBuilder()
|
||||
},
|
||||
onClose: closeAppliedDialog
|
||||
}
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
:variant="buttonVariant ?? 'textonly'"
|
||||
@click="$emit('action')"
|
||||
>
|
||||
<i v-if="buttonIcon" :class="buttonIcon" />
|
||||
{{ buttonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -37,6 +38,7 @@ const props = defineProps<{
|
||||
title?: string
|
||||
message: string
|
||||
textClass?: string
|
||||
buttonIcon?: string
|
||||
buttonLabel?: string
|
||||
buttonVariant?: ButtonVariants['variant']
|
||||
}>()
|
||||
|
||||
@@ -49,6 +49,15 @@ function toggleLinearMode() {
|
||||
metadata: { source }
|
||||
})
|
||||
}
|
||||
|
||||
const tooltipPt = {
|
||||
root: {
|
||||
style: { transform: 'translateX(calc(50% - 16px))' }
|
||||
},
|
||||
arrow: {
|
||||
class: '!left-[16px]'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -58,12 +67,13 @@ function toggleLinearMode() {
|
||||
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
|
||||
>
|
||||
<Button
|
||||
v-tooltip="{
|
||||
v-tooltip.bottom="{
|
||||
value: canvasStore.linearMode
|
||||
? t('breadcrumbsMenu.enterNodeGraph')
|
||||
: t('breadcrumbsMenu.enterAppMode'),
|
||||
showDelay: 300,
|
||||
hideDelay: 300
|
||||
hideDelay: 300,
|
||||
pt: tooltipPt
|
||||
}"
|
||||
:aria-label="
|
||||
canvasStore.linearMode
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
class="flex flex-col border-t border-border-default px-4 py-2 text-sm wrap-break-word text-muted-foreground"
|
||||
>
|
||||
<p v-if="promptTextReal">
|
||||
<p v-if="promptTextReal" :class="preserveNewlines && 'whitespace-pre-line'">
|
||||
{{ promptTextReal }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -11,8 +11,9 @@
|
||||
import { computed, toValue } from 'vue'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
|
||||
const { promptText } = defineProps<{
|
||||
const { promptText, preserveNewlines = false } = defineProps<{
|
||||
promptText?: MaybeRefOrGetter<string>
|
||||
preserveNewlines?: boolean
|
||||
}>()
|
||||
|
||||
const promptTextReal = computed(() => toValue(promptText))
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
:disabled
|
||||
variant="textonly"
|
||||
:variant="confirmVariant ?? 'textonly'"
|
||||
:class="confirmClass"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
@@ -19,13 +19,21 @@ import type { MaybeRefOrGetter } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ButtonVariants } from '@/components/ui/button/button.variants'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { cancelText, confirmText, confirmClass, optionsDisabled } = defineProps<{
|
||||
const {
|
||||
cancelText,
|
||||
confirmText,
|
||||
confirmClass,
|
||||
confirmVariant,
|
||||
optionsDisabled
|
||||
} = defineProps<{
|
||||
cancelText?: string
|
||||
confirmText?: string
|
||||
confirmClass?: string
|
||||
confirmVariant?: ButtonVariants['variant']
|
||||
optionsDisabled?: MaybeRefOrGetter<boolean>
|
||||
}>()
|
||||
|
||||
|
||||
@@ -22,9 +22,8 @@
|
||||
? $t('linearMode.appModeToolbar.appsEmptyMessage')
|
||||
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
|
||||
"
|
||||
:button-label="
|
||||
isAppMode ? undefined : $t('linearMode.appModeToolbar.enterAppMode')
|
||||
"
|
||||
button-icon="icon-[lucide--hammer]"
|
||||
:button-label="isAppMode ? undefined : $t('linearMode.buildAnApp')"
|
||||
@action="enterAppMode"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -40,12 +40,21 @@ const mockMenuItemStore = vi.hoisted(() => ({
|
||||
hasSeenLinear: false
|
||||
}))
|
||||
|
||||
const mockCanvasStore = vi.hoisted(() => ({
|
||||
linearMode: false
|
||||
}))
|
||||
|
||||
const mockAppModeStore = vi.hoisted(() => ({
|
||||
enterBuilder: vi.fn()
|
||||
enterBuilder: vi.fn(),
|
||||
pruneLinearData: vi.fn(
|
||||
(
|
||||
data?: Partial<{
|
||||
inputs: [number | string, string][]
|
||||
outputs: (number | string)[]
|
||||
}>
|
||||
) => ({
|
||||
inputs: data?.inputs ?? [],
|
||||
outputs: data?.outputs ?? []
|
||||
})
|
||||
),
|
||||
selectedInputs: [] as [number | string, string][],
|
||||
selectedOutputs: [] as (number | string)[]
|
||||
}))
|
||||
|
||||
const mockFeatureFlags = vi.hoisted(() => ({
|
||||
@@ -73,10 +82,6 @@ vi.mock('@/stores/menuItemStore', () => ({
|
||||
useMenuItemStore: vi.fn(() => mockMenuItemStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn(() => mockCanvasStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/appModeStore', () => ({
|
||||
useAppModeStore: vi.fn(() => mockAppModeStore)
|
||||
}))
|
||||
@@ -112,8 +117,9 @@ describe('useWorkflowActionsMenu', () => {
|
||||
mockBookmarkStore.isBookmarked.mockReturnValue(false)
|
||||
mockSubgraphStore.isSubgraphBlueprint.mockReturnValue(false)
|
||||
mockMenuItemStore.hasSeenLinear = false
|
||||
mockCanvasStore.linearMode = false
|
||||
mockFeatureFlags.flags.linearToggleEnabled = false
|
||||
mockAppModeStore.selectedInputs.length = 0
|
||||
mockAppModeStore.selectedOutputs.length = 0
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'test.json',
|
||||
isPersisted: true
|
||||
@@ -194,7 +200,11 @@ describe('useWorkflowActionsMenu', () => {
|
||||
|
||||
it('shows "go to workflow mode" when in linear mode', () => {
|
||||
mockFeatureFlags.flags.linearToggleEnabled = true
|
||||
mockCanvasStore.linearMode = true
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'test.json',
|
||||
isPersisted: true,
|
||||
activeMode: 'app'
|
||||
} as ComfyWorkflow
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const labels = menuLabels(menuItems.value)
|
||||
@@ -312,6 +322,22 @@ describe('useWorkflowActionsMenu', () => {
|
||||
expect(mockAppModeStore.enterBuilder).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows "Edit app" when workflow has linear data', async () => {
|
||||
mockFeatureFlags.flags.linearToggleEnabled = true
|
||||
mockWorkflowStore.activeWorkflow = {
|
||||
path: 'test.json',
|
||||
isPersisted: true
|
||||
} as ComfyWorkflow
|
||||
mockAppModeStore.selectedInputs.push([1, 'widget'])
|
||||
mockAppModeStore.selectedOutputs.push(2)
|
||||
|
||||
const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true })
|
||||
const item = findItem(menuItems.value, 'breadcrumbsMenu.editBuilderMode')
|
||||
|
||||
expect(item).toBeDefined()
|
||||
expect(item.isNew).toBeTruthy()
|
||||
})
|
||||
|
||||
it('app mode toggle executes Comfy.ToggleLinear', async () => {
|
||||
mockFeatureFlags.flags.linearToggleEnabled = true
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
useWorkflowBookmarkStore,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useMenuItemStore } from '@/stores/menuItemStore'
|
||||
import { useSubgraphStore } from '@/stores/subgraphStore'
|
||||
@@ -54,9 +53,9 @@ export function useWorkflowActionsMenu(
|
||||
const commandStore = useCommandStore()
|
||||
const subgraphStore = useSubgraphStore()
|
||||
const menuItemStore = useMenuItemStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const { flags } = useFeatureFlags()
|
||||
const { enterBuilder } = useAppModeStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { enterBuilder, pruneLinearData } = appModeStore
|
||||
|
||||
const targetWorkflow = computed(
|
||||
() => workflow?.value ?? workflowStore.activeWorkflow
|
||||
@@ -96,12 +95,15 @@ export function useWorkflowActionsMenu(
|
||||
items.push(item)
|
||||
}
|
||||
|
||||
const isLinearMode = canvasStore.linearMode
|
||||
const workflowMode =
|
||||
workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
|
||||
const isLinearMode = workflowMode === 'app'
|
||||
const showAppModeItems =
|
||||
isRoot && (menuItemStore.hasSeenLinear || flags.linearToggleEnabled)
|
||||
const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '')
|
||||
|
||||
const toggleLinear = async () => {
|
||||
await ensureWorkflowActive(targetWorkflow.value)
|
||||
await commandStore.execute('Comfy.ToggleLinear', {
|
||||
metadata: { source: 'breadcrumb_menu' }
|
||||
})
|
||||
@@ -218,11 +220,31 @@ export function useWorkflowActionsMenu(
|
||||
prependSeparator: true
|
||||
})
|
||||
|
||||
const isActive = workflow === workflowStore.activeWorkflow
|
||||
const rawLd = isActive
|
||||
? {
|
||||
inputs: appModeStore.selectedInputs,
|
||||
outputs: appModeStore.selectedOutputs
|
||||
}
|
||||
: workflow?.changeTracker?.activeState?.extra?.linearData
|
||||
let hasLinearData: boolean
|
||||
if (rawLd) {
|
||||
const { inputs, outputs } = pruneLinearData(rawLd)
|
||||
hasLinearData = inputs.length > 0 || outputs.length > 0
|
||||
} else {
|
||||
hasLinearData = workflow?.path?.endsWith('.app.json') ?? false
|
||||
}
|
||||
|
||||
addItem({
|
||||
id: 'enter-builder-mode',
|
||||
label: t('breadcrumbsMenu.enterBuilderMode'),
|
||||
label: hasLinearData
|
||||
? t('breadcrumbsMenu.editBuilderMode')
|
||||
: t('breadcrumbsMenu.enterBuilderMode'),
|
||||
icon: 'icon-[lucide--hammer]',
|
||||
command: () => enterBuilder(),
|
||||
command: async () => {
|
||||
await ensureWorkflowActive(targetWorkflow.value)
|
||||
enterBuilder()
|
||||
},
|
||||
visible: showAppModeItems,
|
||||
isNew: true
|
||||
})
|
||||
|
||||
@@ -1330,6 +1330,7 @@
|
||||
"Rename": "Rename",
|
||||
"Save": "Save",
|
||||
"Save As": "Save As",
|
||||
"Share": "Share",
|
||||
"Show Settings Dialog": "Show Settings Dialog",
|
||||
"Set Subgraph Description": "Set Subgraph Description",
|
||||
"Set Subgraph Search Aliases": "Set Subgraph Search Aliases",
|
||||
@@ -2598,7 +2599,8 @@
|
||||
"duplicate": "Duplicate",
|
||||
"enterAppMode": "Enter app mode",
|
||||
"exitAppMode": "Exit app mode",
|
||||
"enterBuilderMode": "App builder",
|
||||
"enterBuilderMode": "Build app",
|
||||
"editBuilderMode": "Edit app",
|
||||
"workflowActions": "Workflow actions",
|
||||
"clearWorkflow": "Clear Workflow",
|
||||
"deleteWorkflow": "Delete Workflow",
|
||||
@@ -3007,6 +3009,11 @@
|
||||
"share": "Share",
|
||||
"shareTooltip": "Share workflow"
|
||||
},
|
||||
"shareNoOutputs": {
|
||||
"title": "App has no outputs",
|
||||
"message": "You're about to share an app without outputs. It can't be used until an output is connected.\n\nShare anyway?",
|
||||
"shareAnyway": "Share anyway"
|
||||
},
|
||||
"shareWorkflow": {
|
||||
"shareLinkTab": "Share",
|
||||
"publishToHubTab": "Publish",
|
||||
@@ -3156,6 +3163,7 @@
|
||||
"linearMode": {
|
||||
"linearMode": "App Mode",
|
||||
"beta": "App mode in beta",
|
||||
"buildAnApp": "Build an app",
|
||||
"giveFeedback": "Give feedback",
|
||||
"graphMode": "Graph Mode",
|
||||
"dragAndDropImage": "Click to browse or drag an image",
|
||||
@@ -3176,14 +3184,15 @@
|
||||
"controls": "Your outputs appear at the bottom, your controls are on the right. Everything else stays out of the way.",
|
||||
"sharing": "Share your workflow as a simple tool anyone can use. Export it from the tab menu and when others open it, they'll see App Mode. No node graph knowledge needed.",
|
||||
"getStarted": "Click {runButton} to get started.",
|
||||
"buildApp": "Build app"
|
||||
"buildApp": "Build app",
|
||||
"noOutputs": "An app needs at least {count} to be usable.",
|
||||
"oneOutput": "1 output"
|
||||
},
|
||||
"appModeToolbar": {
|
||||
"appBuilder": "App builder",
|
||||
"apps": "Apps",
|
||||
"appsEmptyMessage": "Saved apps will show up here.",
|
||||
"appsEmptyMessageAction": "Click below to build your first app.",
|
||||
"enterAppMode": "Enter app mode"
|
||||
"appsEmptyMessageAction": "Click below to build your first app."
|
||||
},
|
||||
"arrange": {
|
||||
"noOutputs": "No outputs added yet",
|
||||
@@ -3208,7 +3217,10 @@
|
||||
"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”",
|
||||
"unknownWidget": "Widget not visible"
|
||||
"unknownWidget": "Widget not visible",
|
||||
"inputPlaceholder": "Inputs will show up here",
|
||||
"outputPlaceholder": "Output nodes will show up here",
|
||||
"outputRequiredPlaceholder": "At least one node is required"
|
||||
},
|
||||
"queue": {
|
||||
"clickToClear": "Click to clear queue",
|
||||
@@ -3520,6 +3532,7 @@
|
||||
"defaultModeAppliedGraphBody": "This workflow will open as a node graph by default from now on.",
|
||||
"defaultModeAppliedGraphPrompt": "Would you like to view the app still?",
|
||||
"viewApp": "View app",
|
||||
"exitToWorkflow": "Exit to workflow",
|
||||
"emptyWorkflowTitle": "This workflow has no nodes",
|
||||
"emptyWorkflowPrompt": "Do you want to start with a template?"
|
||||
},
|
||||
|
||||
@@ -1,18 +1,57 @@
|
||||
import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useWorkflowStore } from '../../management/stores/workflowStore'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const DIALOG_KEY = 'global-share-workflow'
|
||||
|
||||
export function useShareDialog() {
|
||||
const dialogService = useDialogService()
|
||||
const dialogStore = useDialogStore()
|
||||
const { pruneLinearData } = useAppModeStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
function hide() {
|
||||
dialogStore.closeDialog({ key: DIALOG_KEY })
|
||||
}
|
||||
|
||||
function show() {
|
||||
function showNoOutputsDialogIfRequired(share: () => void) {
|
||||
const wf = workflowStore.activeWorkflow
|
||||
if (!wf) return share()
|
||||
|
||||
const isAppDefault = wf.initialMode === 'app'
|
||||
const linearData = wf.changeTracker?.activeState?.extra?.linearData
|
||||
const { outputs } = pruneLinearData(linearData)
|
||||
|
||||
if (isAppDefault && outputs.length === 0) {
|
||||
const dialog = showConfirmDialog({
|
||||
headerProps: {
|
||||
title: t('shareNoOutputs.title')
|
||||
},
|
||||
props: {
|
||||
promptText: t('shareNoOutputs.message'),
|
||||
preserveNewlines: true
|
||||
},
|
||||
footerProps: {
|
||||
confirmText: t('shareNoOutputs.shareAnyway'),
|
||||
confirmVariant: 'secondary',
|
||||
onCancel: () => dialogStore.closeDialog(dialog),
|
||||
onConfirm: () => {
|
||||
dialogStore.closeDialog(dialog)
|
||||
share()
|
||||
}
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
share()
|
||||
}
|
||||
|
||||
function showShareDialog() {
|
||||
dialogService.showLayoutDialog({
|
||||
key: DIALOG_KEY,
|
||||
component: ShareWorkflowDialogContent,
|
||||
@@ -29,6 +68,10 @@ export function useShareDialog() {
|
||||
})
|
||||
}
|
||||
|
||||
function show() {
|
||||
showNoOutputsDialogIfRequired(showShareDialog)
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
hide
|
||||
|
||||
@@ -20,18 +20,13 @@ import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
|
||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
const { t } = useI18n()
|
||||
const commandStore = useCommandStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const mediaActions = useMediaAssetActions()
|
||||
const queueStore = useQueueStore()
|
||||
const { isBuilderMode, isArrangeMode } = useAppMode()
|
||||
const { allOutputs } = useOutputHistory()
|
||||
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
|
||||
useOutputHistory()
|
||||
const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
|
||||
runButtonClick?: (e: Event) => void
|
||||
mobile?: boolean
|
||||
@@ -42,12 +37,14 @@ const selectedItem = ref<AssetItem>()
|
||||
const selectedOutput = ref<ResultItemImpl>()
|
||||
const canShowPreview = ref(true)
|
||||
const latentPreview = ref<string>()
|
||||
const showSkeleton = ref(false)
|
||||
|
||||
function handleSelection(sel: OutputSelection) {
|
||||
selectedItem.value = sel.asset
|
||||
selectedOutput.value = sel.output
|
||||
canShowPreview.value = sel.canShowPreview
|
||||
latentPreview.value = sel.latentPreviewUrl
|
||||
showSkeleton.value = sel.showSkeleton ?? false
|
||||
}
|
||||
|
||||
function downloadAsset(item?: AssetItem) {
|
||||
@@ -76,7 +73,7 @@ async function rerun(e: Event) {
|
||||
</script>
|
||||
<template>
|
||||
<section
|
||||
v-if="selectedItem || selectedOutput || !executionStore.isIdle"
|
||||
v-if="selectedItem || selectedOutput || showSkeleton || isWorkflowActive"
|
||||
data-testid="linear-output-info"
|
||||
class="flex w-full flex-wrap justify-center gap-2 p-4 text-sm tabular-nums md:z-10"
|
||||
>
|
||||
@@ -105,9 +102,9 @@ async function rerun(e: Event) {
|
||||
<i class="icon-[lucide--download]" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!executionStore.isIdle && !selectedItem"
|
||||
v-if="isWorkflowActive && !selectedItem"
|
||||
variant="destructive"
|
||||
@click="commandStore.execute('Comfy.Interrupt')"
|
||||
@click="cancelActiveWorkflowJobs()"
|
||||
>
|
||||
<i class="icon-[lucide--x]" />
|
||||
{{ t('linearMode.cancelThisRun') }}
|
||||
@@ -145,7 +142,7 @@ async function rerun(e: Event) {
|
||||
:output="selectedOutput"
|
||||
:mobile
|
||||
/>
|
||||
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
|
||||
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
|
||||
<LinearArrange v-else-if="isArrangeMode" />
|
||||
<LinearWelcome v-else />
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
@@ -18,14 +18,14 @@ const {
|
||||
}>()
|
||||
|
||||
const { totalPercent, currentNodePercent } = useQueueProgress()
|
||||
const queueStore = useQueueStore()
|
||||
const executionStore = useExecutionStore()
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'relative h-2 bg-secondary-background transition-opacity',
|
||||
queueStore.runningTasks.length === 0 && 'opacity-0',
|
||||
!executionStore.isActiveWorkflowRunning && 'opacity-0',
|
||||
rounded && 'rounded-sm',
|
||||
className
|
||||
)
|
||||
|
||||
@@ -4,12 +4,18 @@ import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { setMode } = useAppMode()
|
||||
const appModeStore = useAppModeStore()
|
||||
const { hasOutputs, hasNodes } = storeToRefs(appModeStore)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const isAppDefault = computed(
|
||||
() => workflowStore.activeWorkflow?.initialMode === 'app'
|
||||
)
|
||||
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
|
||||
</script>
|
||||
|
||||
@@ -47,6 +53,18 @@ const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
|
||||
<p v-if="!hasNodes" class="mt-0 max-w-md text-sm text-base-foreground">
|
||||
{{ t('linearMode.emptyWorkflowExplanation') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="hasNodes && isAppDefault"
|
||||
class="mt-0 max-w-md text-sm text-base-foreground"
|
||||
>
|
||||
<i18n-t keypath="linearMode.welcome.noOutputs" tag="span">
|
||||
<template #count>
|
||||
<span class="font-bold text-warning-background">{{
|
||||
t('linearMode.welcome.oneOutput')
|
||||
}}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</p>
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button variant="textonly" size="lg" @click="setMode('graph')">
|
||||
{{ t('linearMode.backToWorkflow') }}
|
||||
|
||||
@@ -25,12 +25,15 @@ import type {
|
||||
} from '@/renderer/extensions/linearMode/linearModeTypes'
|
||||
import OutputPreviewItem from '@/renderer/extensions/linearMode/OutputPreviewItem.vue'
|
||||
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { outputs, allOutputs, selectFirstHistory } = useOutputHistory()
|
||||
const { outputs, allOutputs, selectFirstHistory, mayBeActiveWorkflowPending } =
|
||||
useOutputHistory()
|
||||
const queueStore = useQueueStore()
|
||||
const store = useLinearOutputStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateSelection: [selection: OutputSelection]
|
||||
@@ -55,10 +58,7 @@ const visibleHistory = computed(() =>
|
||||
|
||||
const selectableItems = computed(() => {
|
||||
const items: SelectionValue[] = []
|
||||
if (
|
||||
queueCount.value > 0 &&
|
||||
store.activeWorkflowInProgressItems.length === 0
|
||||
) {
|
||||
if (mayBeActiveWorkflowPending.value) {
|
||||
items.push({ id: 'slot:pending', kind: 'inProgress', itemId: 'pending' })
|
||||
}
|
||||
for (const item of store.activeWorkflowInProgressItems) {
|
||||
@@ -120,7 +120,7 @@ function doEmit() {
|
||||
(i) => i.id === sel.itemId
|
||||
)
|
||||
if (!item || item.state === 'skeleton') {
|
||||
emit('updateSelection', { canShowPreview: true })
|
||||
emit('updateSelection', { canShowPreview: true, showSkeleton: true })
|
||||
} else if (item.state === 'latent') {
|
||||
emit('updateSelection', {
|
||||
canShowPreview: true,
|
||||
@@ -146,6 +146,23 @@ function doEmit() {
|
||||
|
||||
watchEffect(doEmit)
|
||||
|
||||
// On load or workflow tab switch, select the most recent item.
|
||||
// Prefer in-progress items for this workflow, then history, skipping
|
||||
// the global pending slot which may belong to another workflow.
|
||||
watch(
|
||||
() => workflowStore.activeWorkflow?.path,
|
||||
(path) => {
|
||||
if (!path) return
|
||||
const inProgress = store.activeWorkflowInProgressItems
|
||||
if (inProgress.length > 0) {
|
||||
store.selectAsLatest(`slot:${inProgress[0].id}`)
|
||||
} else {
|
||||
selectFirstHistory()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Keep history selection stable on media changes
|
||||
watch(
|
||||
() => outputs.media.value,
|
||||
@@ -303,9 +320,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
queueCount > 0 && store.activeWorkflowInProgressItems.length === 0
|
||||
"
|
||||
v-if="mayBeActiveWorkflowPending"
|
||||
:ref="selectedRef('slot:pending')"
|
||||
v-bind="itemAttrs('slot:pending')"
|
||||
:class="itemClass"
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface OutputSelection {
|
||||
output?: ResultItemImpl
|
||||
canShowPreview: boolean
|
||||
latentPreviewUrl?: string
|
||||
showSkeleton?: boolean
|
||||
}
|
||||
|
||||
export type SelectionValue =
|
||||
|
||||
@@ -124,6 +124,7 @@ describe('linearOutputStore', () => {
|
||||
|
||||
it('auto-selects skeleton on first job start when no selection', () => {
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
expect(store.selectedId).toBe(`slot:${store.inProgressItems[0].id}`)
|
||||
@@ -132,6 +133,7 @@ describe('linearOutputStore', () => {
|
||||
it('transitions to latent on preview', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
const itemId = store.inProgressItems[0].id
|
||||
@@ -265,6 +267,7 @@ describe('linearOutputStore', () => {
|
||||
// selectAsLatest simulates "following the latest output"
|
||||
store.selectAsLatest('history:asset-1:0')
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// Following latest → auto-select new skeleton
|
||||
@@ -286,6 +289,7 @@ describe('linearOutputStore', () => {
|
||||
|
||||
it('falls back selection when selected item is removed', () => {
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
const firstId = `slot:${store.inProgressItems[0].id}`
|
||||
expect(store.selectedId).toBe(firstId)
|
||||
@@ -400,6 +404,8 @@ describe('linearOutputStore', () => {
|
||||
|
||||
it('two sequential runs: selection clears after each resolve', () => {
|
||||
const store = useLinearOutputStore()
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
||||
|
||||
// Run 1: 3 outputs
|
||||
store.onJobStart('job-1')
|
||||
@@ -738,6 +744,34 @@ describe('linearOutputStore', () => {
|
||||
expect(imageItems[0].output?.nodeId).toBe('2')
|
||||
})
|
||||
|
||||
it('does not auto-select for jobs belonging to another workflow', () => {
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
// User is on workflow-b, following latest
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
store.selectAsLatest('history:asset-b:0')
|
||||
|
||||
// Job from workflow-a starts
|
||||
setJobWorkflowPath('job-1', 'workflows/app-a.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// Should NOT yank selection to the other workflow's slot
|
||||
expect(store.selectedId).toBe('history:asset-b:0')
|
||||
})
|
||||
|
||||
it('auto-selects for jobs belonging to the active workflow', () => {
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
store.selectAsLatest('history:asset-a:0')
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/app-a.json')
|
||||
store.onJobStart('job-1')
|
||||
|
||||
// Should auto-select since job matches active workflow
|
||||
expect(store.selectedId?.startsWith('slot:')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores execution events when not in app mode', async () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
@@ -67,7 +67,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
inProgressItems.value = [item, ...inProgressItems.value]
|
||||
|
||||
trackedJobId.value = jobId
|
||||
autoSelect(`slot:${item.id}`)
|
||||
autoSelect(`slot:${item.id}`, jobId)
|
||||
}
|
||||
|
||||
let raf: number | null = null
|
||||
@@ -88,7 +88,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
state: 'latent',
|
||||
latentPreviewUrl: url
|
||||
}))
|
||||
if (wasEmpty) autoSelect(`slot:${existing.id}`)
|
||||
if (wasEmpty) autoSelect(`slot:${existing.id}`, jobId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
}
|
||||
currentSkeletonId.value = item.id
|
||||
inProgressItems.value = [item, ...inProgressItems.value]
|
||||
autoSelect(`slot:${item.id}`)
|
||||
autoSelect(`slot:${item.id}`, jobId)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
output: newOutputs[0],
|
||||
latentPreviewUrl: undefined
|
||||
}
|
||||
autoSelect(`slot:${imageItem.id}`)
|
||||
autoSelect(`slot:${imageItem.id}`, jobId)
|
||||
|
||||
const extras: InProgressItem[] = newOutputs.slice(1).map((o) => ({
|
||||
id: makeItemId(jobId),
|
||||
@@ -162,7 +162,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
state: 'image' as const,
|
||||
output: o
|
||||
}))
|
||||
autoSelect(`slot:${newItems[0].id}`)
|
||||
autoSelect(`slot:${newItems[0].id}`, jobId)
|
||||
inProgressItems.value = [...newItems, ...inProgressItems.value]
|
||||
}
|
||||
|
||||
@@ -226,7 +226,12 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
isFollowing.value = true
|
||||
}
|
||||
|
||||
function autoSelect(slotId: string) {
|
||||
function autoSelect(slotId: string, jobId: string) {
|
||||
// Only auto-select if the job belongs to the active workflow
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (path && executionStore.jobIdToSessionWorkflowPath.get(jobId) !== path)
|
||||
return
|
||||
|
||||
const sel = selectedId.value
|
||||
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
|
||||
selectedId.value = slotId
|
||||
|
||||
@@ -11,9 +11,13 @@ import { ResultItemImpl } from '@/stores/queueStore'
|
||||
const mediaRef = ref<AssetItem[]>([])
|
||||
const pendingResolveRef = ref(new Set<string>())
|
||||
const inProgressItemsRef = ref<InProgressItem[]>([])
|
||||
const activeWorkflowInProgressItemsRef = ref<InProgressItem[]>([])
|
||||
const selectedIdRef = ref<string | null>(null)
|
||||
const activeWorkflowPathRef = ref<string>('workflows/test.json')
|
||||
const jobIdToPathRef = ref(new Map<string, string>())
|
||||
const isActiveWorkflowRunningRef = ref(false)
|
||||
const runningTasksRef = ref<Array<{ jobId: string }>>([])
|
||||
const pendingTasksRef = ref<Array<{ jobId: string }>>([])
|
||||
|
||||
const selectAsLatestFn = vi.fn()
|
||||
const resolveIfReadyFn = vi.fn()
|
||||
@@ -40,6 +44,9 @@ vi.mock('@/renderer/extensions/linearMode/linearOutputStore', () => ({
|
||||
get inProgressItems() {
|
||||
return inProgressItemsRef.value
|
||||
},
|
||||
get activeWorkflowInProgressItems() {
|
||||
return activeWorkflowInProgressItemsRef.value
|
||||
},
|
||||
get selectedId() {
|
||||
return selectedIdRef.value
|
||||
},
|
||||
@@ -61,10 +68,27 @@ vi.mock('@/stores/executionStore', () => ({
|
||||
useExecutionStore: () => ({
|
||||
get jobIdToSessionWorkflowPath() {
|
||||
return jobIdToPathRef.value
|
||||
},
|
||||
get isActiveWorkflowRunning() {
|
||||
return isActiveWorkflowRunningRef.value
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/queueStore', async (importOriginal) => {
|
||||
return {
|
||||
...(await importOriginal()),
|
||||
useQueueStore: () => ({
|
||||
get runningTasks() {
|
||||
return runningTasksRef.value
|
||||
},
|
||||
get pendingTasks() {
|
||||
return pendingTasksRef.value
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const { jobDetailResults } = vi.hoisted(() => ({
|
||||
jobDetailResults: new Map<string, unknown>()
|
||||
}))
|
||||
@@ -128,9 +152,13 @@ describe(useOutputHistory, () => {
|
||||
mediaRef.value = []
|
||||
pendingResolveRef.value = new Set()
|
||||
inProgressItemsRef.value = []
|
||||
activeWorkflowInProgressItemsRef.value = []
|
||||
selectedIdRef.value = null
|
||||
activeWorkflowPathRef.value = 'workflows/test.json'
|
||||
jobIdToPathRef.value = new Map()
|
||||
isActiveWorkflowRunningRef.value = false
|
||||
runningTasksRef.value = []
|
||||
pendingTasksRef.value = []
|
||||
resolvedOutputsCacheRef.clear()
|
||||
jobDetailResults.clear()
|
||||
selectAsLatestFn.mockReset()
|
||||
@@ -378,4 +406,54 @@ describe(useOutputHistory, () => {
|
||||
expect(selectAsLatestFn).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mayBeActiveWorkflowPending', () => {
|
||||
it('returns false when no tasks are queued', () => {
|
||||
const { mayBeActiveWorkflowPending } = useOutputHistory()
|
||||
expect(mayBeActiveWorkflowPending.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when there are active in-progress items', () => {
|
||||
activeWorkflowInProgressItemsRef.value = [
|
||||
{ id: 'item-1', jobId: 'job-1', state: 'skeleton' }
|
||||
]
|
||||
runningTasksRef.value = [{ jobId: 'job-1' }]
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||
|
||||
const { mayBeActiveWorkflowPending } = useOutputHistory()
|
||||
expect(mayBeActiveWorkflowPending.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when a running task matches the active workflow', () => {
|
||||
runningTasksRef.value = [{ jobId: 'job-1' }]
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||
|
||||
const { mayBeActiveWorkflowPending } = useOutputHistory()
|
||||
expect(mayBeActiveWorkflowPending.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true when a pending task matches the active workflow', () => {
|
||||
pendingTasksRef.value = [{ jobId: 'job-1' }]
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
|
||||
|
||||
const { mayBeActiveWorkflowPending } = useOutputHistory()
|
||||
expect(mayBeActiveWorkflowPending.value).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when tasks belong to another workflow', () => {
|
||||
runningTasksRef.value = [{ jobId: 'job-1' }]
|
||||
jobIdToPathRef.value = new Map([['job-1', 'workflows/other.json']])
|
||||
|
||||
const { mayBeActiveWorkflowPending } = useOutputHistory()
|
||||
expect(mayBeActiveWorkflowPending.value).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no workflow path is set', () => {
|
||||
activeWorkflowPathRef.value = ''
|
||||
runningTasksRef.value = [{ jobId: 'job-1' }]
|
||||
|
||||
const { mayBeActiveWorkflowPending } = useOutputHistory()
|
||||
expect(mayBeActiveWorkflowPending.value).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import type { ComputedRef } from 'vue'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
|
||||
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
|
||||
@@ -9,14 +10,20 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
|
||||
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
|
||||
import { getJobDetail } from '@/services/jobOutputCache'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import { useQueueStore } from '@/stores/queueStore'
|
||||
import type { ResultItemImpl } from '@/stores/queueStore'
|
||||
|
||||
export function useOutputHistory(): {
|
||||
outputs: IAssetsProvider
|
||||
allOutputs: (item?: AssetItem) => ResultItemImpl[]
|
||||
selectFirstHistory: () => void
|
||||
mayBeActiveWorkflowPending: ComputedRef<boolean>
|
||||
isWorkflowActive: ComputedRef<boolean>
|
||||
cancelActiveWorkflowJobs: () => Promise<void>
|
||||
} {
|
||||
const backingOutputs = useMediaAssets('output')
|
||||
void backingOutputs.fetchMediaList()
|
||||
@@ -24,6 +31,37 @@ export function useOutputHistory(): {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const appModeStore = useAppModeStore()
|
||||
const queueStore = useQueueStore()
|
||||
|
||||
function matchesActiveWorkflow(task: { jobId: string | number }): boolean {
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (!path) return false
|
||||
return (
|
||||
executionStore.jobIdToSessionWorkflowPath.get(String(task.jobId)) === path
|
||||
)
|
||||
}
|
||||
|
||||
function hasActiveWorkflowJobs(): boolean {
|
||||
if (!workflowStore.activeWorkflow?.path) return false
|
||||
return (
|
||||
queueStore.runningTasks.some(matchesActiveWorkflow) ||
|
||||
queueStore.pendingTasks.some(matchesActiveWorkflow)
|
||||
)
|
||||
}
|
||||
|
||||
// True when there are queued/running jobs for the active workflow but no
|
||||
// in-progress output items yet.
|
||||
const mayBeActiveWorkflowPending = computed(() => {
|
||||
if (linearStore.activeWorkflowInProgressItems.length > 0) return false
|
||||
return hasActiveWorkflowJobs()
|
||||
})
|
||||
|
||||
// True when the active workflow has running/pending jobs or in-progress items.
|
||||
const isWorkflowActive = computed(
|
||||
() =>
|
||||
linearStore.activeWorkflowInProgressItems.length > 0 ||
|
||||
hasActiveWorkflowJobs()
|
||||
)
|
||||
|
||||
function filterByOutputNodes(items: ResultItemImpl[]): ResultItemImpl[] {
|
||||
const nodeIds = appModeStore.selectedOutputs
|
||||
@@ -140,5 +178,29 @@ export function useOutputHistory(): {
|
||||
}
|
||||
})
|
||||
|
||||
return { outputs, allOutputs, selectFirstHistory }
|
||||
async function cancelActiveWorkflowJobs() {
|
||||
if (!workflowStore.activeWorkflow?.path) return
|
||||
|
||||
// Interrupt the running job if it belongs to this workflow
|
||||
if (queueStore.runningTasks.some(matchesActiveWorkflow)) {
|
||||
void useCommandStore().execute('Comfy.Interrupt')
|
||||
} else {
|
||||
// Delete first pending job for this workflow from the queue
|
||||
for (const task of queueStore.pendingTasks) {
|
||||
if (matchesActiveWorkflow(task)) {
|
||||
await api.deleteItem('queue', String(task.jobId))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
outputs,
|
||||
allOutputs,
|
||||
selectFirstHistory,
|
||||
mayBeActiveWorkflowPending,
|
||||
isWorkflowActive,
|
||||
cancelActiveWorkflowJobs
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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 { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
@@ -26,20 +27,25 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
return !!app.rootGraph?.nodes?.length
|
||||
})
|
||||
|
||||
function loadSelections(data: Partial<LinearData> | undefined) {
|
||||
// Prune entries referencing nodes deleted in workflow mode.
|
||||
// Only check node existence, not widgets — dynamic widgets can
|
||||
// hide/show other widgets so a missing widget does not mean stale data.
|
||||
function pruneLinearData(data: Partial<LinearData> | undefined): LinearData {
|
||||
const rawInputs = data?.inputs ?? []
|
||||
const rawOutputs = data?.outputs ?? []
|
||||
|
||||
// Prune entries referencing nodes deleted in workflow mode.
|
||||
// Only check node existence, not widgets — dynamic widgets can
|
||||
// hide/show other widgets so a missing widget does not mean stale data.
|
||||
const inputs = app.rootGraph
|
||||
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
|
||||
: rawInputs
|
||||
const outputs = app.rootGraph
|
||||
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
|
||||
: rawOutputs
|
||||
return {
|
||||
inputs: app.rootGraph
|
||||
? rawInputs.filter(([nodeId]) => resolveNode(nodeId))
|
||||
: rawInputs,
|
||||
outputs: app.rootGraph
|
||||
? rawOutputs.filter((nodeId) => resolveNode(nodeId))
|
||||
: rawOutputs
|
||||
}
|
||||
}
|
||||
|
||||
function loadSelections(data: Partial<LinearData> | undefined) {
|
||||
const { inputs, outputs } = pruneLinearData(data)
|
||||
selectedInputs.splice(0, selectedInputs.length, ...inputs)
|
||||
selectedOutputs.splice(0, selectedOutputs.length, ...outputs)
|
||||
}
|
||||
@@ -105,6 +111,8 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
return
|
||||
}
|
||||
|
||||
useSidebarTabStore().activeSidebarTabId = null
|
||||
|
||||
setMode(
|
||||
mode.value === 'app' && hasOutputs.value
|
||||
? 'builder:arrange'
|
||||
@@ -112,7 +120,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
)
|
||||
}
|
||||
|
||||
async function exitBuilder() {
|
||||
function exitBuilder() {
|
||||
resetSelectedToWorkflow()
|
||||
setMode('graph')
|
||||
}
|
||||
@@ -122,6 +130,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
exitBuilder,
|
||||
hasNodes,
|
||||
hasOutputs,
|
||||
pruneLinearData,
|
||||
resetSelectedToWorkflow,
|
||||
selectedInputs,
|
||||
selectedOutputs
|
||||
|
||||
@@ -549,6 +549,13 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
() => runningJobIds.value.length
|
||||
)
|
||||
|
||||
const isActiveWorkflowRunning = computed(() => {
|
||||
if (!activeJobId.value) return false
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (!path) return false
|
||||
return jobIdToSessionWorkflowPath.value.get(activeJobId.value) === path
|
||||
})
|
||||
|
||||
return {
|
||||
isIdle,
|
||||
clientId,
|
||||
@@ -568,6 +575,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
runningJobIds,
|
||||
runningWorkflowCount,
|
||||
initializingJobIds,
|
||||
isActiveWorkflowRunning,
|
||||
isJobInitializing,
|
||||
clearInitializationByJobId,
|
||||
clearInitializationByJobIds,
|
||||
|
||||
Reference in New Issue
Block a user