Compare commits

...

5 Commits

Author SHA1 Message Date
Hunter Senft-Grupp
26b50f484d fix: update copy on cloud login title and profile popover sign out 2026-03-06 22:23:04 -05:00
pythongosssss
1058b7d12d 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)
2026-03-06 18:57:03 -08:00
jaeone94
8bfd93963f [style] Update error/subgraph node footer design with layered overlay approach (#9360)
## Summary

Refactors the error and subgraph node footer UI by extracting a
dedicated `NodeFooter` component and replacing the CSS `outline`
approach with a layered border overlay for selection/executing state
indicators.

## Changes

- **What**: Extracted `NodeFooter.vue` from `LGraphNode.vue` to
encapsulate the footer tab logic (subgraph enter, error, advanced
inputs). Replaced CSS `outline` with an absolutely-positioned border
overlay div for selection and executing state. Added a separate root
border overlay div for the node body border. Removed unused
`isTransparent` function from `colorUtil.ts`.
- **Dependencies**: None

## Review Focus

- The layered overlay approach (`absolute -inset-[3px] border-3`) for
selection/executing outlines vs the previous `outline-3` approach —
ensures the outline renders outside the node bounds correctly including
the footer area
- `NodeFooter` handles 4 cases: subgraph+error (dual tabs), error only,
subgraph only, advanced inputs — verify edge cases render correctly
- Resize handle bottom offset adjustments for nodes with footers
(`hasFooter`)

## Screenshots
<img width="1142" height="603" alt="image"
src="https://github.com/user-attachments/assets/e0d401f0-8516-4f5f-ab77-48a79530f4bd"
/>
<img width="1175" height="577" alt="image"
src="https://github.com/user-attachments/assets/bcf08fff-728a-491c-add9-5b96d2f3bfce"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9360-style-Update-error-subgraph-node-footer-design-with-layered-overlay-approach-3186d73d365081b2ac31f166f4d1944a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-03-06 17:51:08 -08:00
Benjamin Lu
3366079f59 test: disable missing model warnings in browser tests (#9513)
Disable missing model warnings in browser tests by default.

Browser tests run without model files on disk, so workflows that embed
model metadata can render differently in CI than the test actually
intends to cover. The viewport screenshot golden had started depending
on the missing-model popup even though the test is only about restoring
an offscreen viewport.

Set `Comfy.Workflow.ShowMissingModelsWarning` to `false` in the shared
Playwright fixture, keep the missing-model dialog coverage by explicitly
enabling the setting in the dialog tests, and update the viewport
screenshot expectation to the no-popup rendering.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9513-test-disable-missing-model-warnings-in-browser-tests-31b6d73d365081d1908bfe11ec0c3bc2)
by [Unito](https://www.unito.io)
2026-03-06 17:37:50 -08:00
Johnpaul Chiwetelu
c4dabb8f98 refactor: extract input widget resolution from SubgraphNode configure (#9383)
## Summary

Extract the inner link-resolution loop from
`_internalConfigureAfterSlots` into a private `_resolveInputWidget`
method to reduce cognitive complexity below the sonarjs threshold of 15.

## Changes

- **What**: Extract nested loop body (lines 654-689) into
`_resolveInputWidget` private method in `SubgraphNode.ts`
- Pure refactoring with no behavioral changes

## Review Focus

Straightforward extract-method refactoring. The new method contains the
exact same logic that was previously inline.

Fixes #9297

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9383-refactor-extract-input-widget-resolution-from-SubgraphNode-configure-3196d73d365081ba9124cfd0d312fcb0)
by [Unito](https://www.unito.io)
2026-03-06 23:24:49 +01:00
63 changed files with 1016 additions and 362 deletions

View File

@@ -36,14 +36,7 @@
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"cnr_id": "comfy-core",
"ver": "0.3.65",
"models": [
{
"name": "v1-5-pruned-emaonly-fp16.safetensors",
"url": "https://huggingface.co/Comfy-Org/stable-diffusion-v1-5-archive/resolve/main/v1-5-pruned-emaonly-fp16.safetensors?download=true",
"directory": "checkpoints"
}
]
"ver": "0.3.65"
},
"widgets_values": ["v1-5-pruned-emaonly-fp16.safetensors"]
},

View File

@@ -432,7 +432,10 @@ export const comfyPageFixture = base.extend<{
'Comfy.VueNodes.AutoScaleLayout': false,
// Disable toast warning about version compatibility, as they may or
// may not appear - depending on upstream ComfyUI dependencies
'Comfy.VersionCompatibility.DisableWarnings': true
'Comfy.VersionCompatibility.DisableWarnings': true,
// Browser tests should opt into missing-model warnings explicitly so
// workflows do not render differently based on models present on disk.
'Comfy.Workflow.ShowMissingModelsWarning': false
})
} catch (e) {
console.error(e)

View File

@@ -172,6 +172,19 @@ export class VueNodeHelpers {
async enterSubgraph(nodeId?: string): Promise<void> {
const locator = nodeId ? this.getNodeLocator(nodeId) : this.page
const editButton = locator.getByTestId(TestIds.widgets.subgraphEnterButton)
await editButton.click()
// The footer tab button extends below the node body (visible area),
// but its bounding box center overlaps the node body div.
// Click at the bottom 25% of the button which is the genuinely visible
// and unobstructed area outside the node body boundary.
const box = await editButton.boundingBox()
if (!box) {
throw new Error(
'subgraph-enter-button has no bounding box: element may be hidden or not in DOM'
)
}
await editButton.click({
position: { x: box.width / 2, y: box.height * 0.75 }
})
}
}

View File

@@ -89,6 +89,17 @@ test.describe('Execution error', () => {
})
test.describe('Missing models warning', () => {
test('Should be disabled by default in browser tests', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_models')
const dialogTitle = comfyPage.page.getByText(
'This workflow is missing models'
)
await expect(dialogTitle).not.toBeVisible()
})
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.ShowMissingModelsWarning',

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -22,8 +22,10 @@ test.describe('Vue Node Bypass', () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
const checkpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const checkpointNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Load Checkpoint' })
.getByTestId('node-inner-wrapper')
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
@@ -41,8 +43,14 @@ test.describe('Vue Node Bypass', () => {
await comfyPage.page.getByText('Load Checkpoint').click()
await comfyPage.page.getByText('KSampler').click({ modifiers: ['Control'] })
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
const checkpointNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Load Checkpoint' })
.getByTestId('node-inner-wrapper')
const ksamplerNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'KSampler' })
.getByTestId('node-inner-wrapper')
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View File

@@ -3,7 +3,7 @@ import {
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
const ERROR_CLASS = /border-node-stroke-error/
const ERROR_CLASS = /ring-destructive-background/
test.describe('Vue Node Error', () => {
test.beforeEach(async ({ comfyPage }) => {
@@ -18,9 +18,10 @@ test.describe('Vue Node Error', () => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
// Expect error state on missing unknown node
const unknownNode = comfyPage.page.locator('[data-node-id]').filter({
hasText: 'UNKNOWN NODE'
})
const unknownNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'UNKNOWN NODE' })
.getByTestId('node-inner-wrapper')
await expect(unknownNode).toHaveClass(ERROR_CLASS)
})
@@ -31,7 +32,10 @@ test.describe('Vue Node Error', () => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
await comfyPage.runButton.click()
const raiseErrorNode = comfyPage.vueNodes.getNodeByTitle('Raise Error')
const raiseErrorNode = comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: 'Raise Error' })
.getByTestId('node-inner-wrapper')
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -58,6 +58,6 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => {
})
function onExitBuilder() {
void appModeStore.exitBuilder()
appModeStore.exitBuilder()
}
</script>

View File

@@ -94,7 +94,7 @@ function onEnterAppMode(close: () => void) {
}
function onExitBuilder(close: () => void) {
void appModeStore.exitBuilder()
appModeStore.exitBuilder()
close()
}
</script>

View File

@@ -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)

View File

@@ -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
}
})

View File

@@ -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']
}>()

View File

@@ -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

View File

@@ -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))

View File

@@ -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>
}>()

View File

@@ -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>

View File

@@ -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

View File

@@ -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
})

View File

@@ -649,49 +649,52 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
this._addSubgraphInputListeners(subgraphInput, input)
// Find the first widget that this slot is connected to
for (const linkId of subgraphInput.linkIds) {
const link = this.subgraph.getLink(linkId)
if (!link) {
console.warn(
`[SubgraphNode.configure] No link found for link ID ${linkId}`,
this
)
continue
}
const { inputNode } = link.resolve(this.subgraph)
if (!inputNode) {
console.warn('Failed to resolve inputNode', link, this)
continue
}
//Manually find input since target_slot can't be trusted
const targetInput = inputNode.inputs.find((inp) => inp.link === linkId)
if (!targetInput) {
console.warn('Failed to find corresponding input', link, inputNode)
continue
}
// No widget - ignore this link
const widget = inputNode.getWidgetFromSlot(targetInput)
if (!widget) continue
this._setWidget(
subgraphInput,
input,
widget,
targetInput.widget,
inputNode
)
break
}
this._resolveInputWidget(subgraphInput, input)
}
this._syncPromotions()
}
private _resolveInputWidget(
subgraphInput: SubgraphInput,
input: INodeInputSlot
) {
for (const linkId of subgraphInput.linkIds) {
const link = this.subgraph.getLink(linkId)
if (!link) {
console.warn(
`[SubgraphNode.configure] No link found for link ID ${linkId}`,
this
)
continue
}
const { inputNode } = link.resolve(this.subgraph)
if (!inputNode) {
console.warn('Failed to resolve inputNode', link, this)
continue
}
const targetInput = inputNode.inputs.find((inp) => inp.link === linkId)
if (!targetInput) {
console.warn('Failed to find corresponding input', link, inputNode)
continue
}
const widget = inputNode.getWidgetFromSlot(targetInput)
if (!widget) continue
this._setWidget(
subgraphInput,
input,
widget,
targetInput.widget,
inputNode
)
break
}
}
private _setWidget(
subgraphInput: Readonly<SubgraphInput>,
input: INodeInputSlot,

View File

@@ -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",
@@ -2024,7 +2025,7 @@
"whitelistInfo": "About non-whitelisted sites"
},
"login": {
"title": "Log in to your account",
"title": "Welcome back! Log in to your account",
"useApiKey": "Comfy API Key",
"signInOrSignUp": "Sign In / Sign Up",
"forgotPasswordError": "Failed to send password reset email",
@@ -2081,7 +2082,7 @@
"emailNotEligibleForFreeTier": "Email sign-up is not eligible for Free Tier."
},
"signOut": {
"signOut": "Log Out",
"signOut": "Sign Out",
"success": "Signed out successfully",
"successDetail": "You have been signed out of your account.",
"unsavedChangesTitle": "Unsaved Changes",
@@ -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?"
},

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View File

@@ -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') }}

View File

@@ -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"

View File

@@ -14,6 +14,7 @@ export interface OutputSelection {
output?: ResultItemImpl
canShowPreview: boolean
latentPreviewUrl?: string
showSkeleton?: boolean
}
export type SelectionValue =

View File

@@ -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()

View File

@@ -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

View File

@@ -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)
})
})
})

View File

@@ -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
}
}

View File

@@ -41,6 +41,23 @@ vi.mock(
})
)
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { getNodeById: vi.fn() },
canvas: { setDirty: vi.fn() }
}
}))
vi.mock('@/utils/graphTraversalUtil', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
getNodeByLocatorId: vi.fn(() => ({
isSubgraphNode: () => false
}))
}
})
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
toastErrorHandler: vi.fn()
@@ -184,8 +201,13 @@ describe('LGraphNode', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.classes()).toContain('outline-3')
// Root div should have the selection class
expect(wrapper.classes()).toContain('outline-node-component-outline')
// The layered outline overlay should be present
const overlay = wrapper.find('[data-testid="node-state-outline-overlay"]')
expect(overlay.exists()).toBe(true)
expect(overlay.classes()).toContain('border-node-component-outline')
})
it('should render progress indicator when executing prop is true', () => {
@@ -193,7 +215,13 @@ describe('LGraphNode', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
// Root div should have the executing class
expect(wrapper.classes()).toContain('outline-node-stroke-executing')
// The layered outline overlay should be present
const overlay = wrapper.find('[data-testid="node-state-outline-overlay"]')
expect(overlay.exists()).toBe(true)
expect(overlay.classes()).toContain('border-node-stroke-executing')
})
it('should initialize height CSS vars for collapsed nodes', () => {

View File

@@ -9,25 +9,11 @@
:data-node-id="nodeData.id"
:class="
cn(
'group/node lg-node absolute bg-node-component-header-surface text-sm',
'min-h-(--node-height) w-(--node-width) min-w-[225px] contain-layout contain-style',
shapeClass,
'flex touch-none flex-col',
'border border-solid border-component-node-border',
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'ring-node-component-ring hover:ring-7',
'outline-3 outline-transparent focus-visible:outline-node-component-outline',
borderClass,
outlineClass,
'group/node lg-node absolute text-sm',
'flex min-w-[225px] flex-col contain-layout contain-style',
cursorClass,
{
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0 before:bg-bypass/60`]:
bypassed,
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
muted,
'bg-primary-500/10 ring-4 ring-primary-500': isDraggingOver
},
isSelected && 'outline-node-component-outline',
executing && 'outline-node-stroke-executing',
shouldHandleNodePointerEvents && !nodeData.flags?.ghost
? 'pointer-events-auto'
: 'pointer-events-none'
@@ -37,9 +23,7 @@
{
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex,
opacity: nodeOpacity,
'--component-node-background': applyLightThemeColor(nodeData.bgcolor),
backgroundColor: applyLightThemeColor(nodeData?.color)
opacity: nodeOpacity
}
]"
v-bind="remainingPointerHandlers"
@@ -50,6 +34,7 @@
@dragleave="handleDragLeave"
@drop.stop.prevent="handleDrop"
>
<!-- Selection/Execution Outline Overlay -->
<AppOutput
v-if="
lgraphNode?.constructor?.nodeData?.output_node &&
@@ -59,166 +44,163 @@
:id="nodeData.id"
/>
<div
v-if="displayHeader"
class="relative flex flex-col items-center justify-center"
>
<template v-if="isCollapsed">
<SlotConnectionDot
v-if="hasInputs"
multi
class="absolute left-0 -translate-x-1/2"
/>
<SlotConnectionDot
v-if="hasOutputs"
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data="nodeData" unified />
</template>
<NodeHeader
:node-data="nodeData"
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate"
/>
</div>
<div
v-if="isCollapsed && executing && progress !== undefined"
v-if="isSelected || executing"
data-testid="node-state-outline-overlay"
:class="
cn(
'absolute inset-x-4 -bottom-px translate-y-1/2 rounded-full',
progressClasses
'pointer-events-none absolute z-0 border-3 outline-none',
selectionShapeClass,
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing',
footerStateOutlineBottomClass
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
<template v-if="!isCollapsed">
<div class="relative">
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
:class="
cn(
'absolute inset-x-0 top-1/2 -translate-y-1/2',
!!(progress < 1) && 'rounded-r-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
<!-- Root Border Overlay -->
<div
:class="
cn(
'pointer-events-none absolute border border-solid border-component-node-border',
rootBorderShapeClass,
hasAnyError ? '-inset-1' : 'inset-0',
footerRootBorderBottomClass
)
"
/>
<div
data-testid="node-inner-wrapper"
:class="
cn(
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
'min-h-(--node-height) w-(--node-width)',
shapeClass,
hasAnyError && 'ring-4 ring-destructive-background',
{
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0 before:bg-bypass/60`]:
bypassed,
[`${beforeShapeClass} before:pointer-events-none before:absolute before:inset-0`]:
muted,
'bg-primary-500/10 ring-4 ring-primary-500': isDraggingOver
}
)
"
:style="{
'--component-node-background': applyLightThemeColor(nodeData.bgcolor),
backgroundColor: applyLightThemeColor(nodeData?.color)
}"
>
<AppOutput
v-if="lgraphNode?.constructor?.nodeData?.output_node && isSelectMode"
:id="nodeData.id"
/>
<div
v-if="displayHeader"
class="relative flex flex-col items-center justify-center"
>
<template v-if="isCollapsed">
<SlotConnectionDot
v-if="hasInputs"
multi
class="absolute left-0 -translate-x-1/2"
/>
<SlotConnectionDot
v-if="hasOutputs"
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data="nodeData" unified />
</template>
<NodeHeader
:node-data="nodeData"
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate"
/>
</div>
<div
class="flex flex-1 flex-col gap-1 rounded-b-2xl bg-component-node-background pt-1 pb-3"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />
v-if="isCollapsed && executing && progress !== undefined"
:class="
cn(
'absolute inset-x-4 -bottom-px translate-y-1/2 rounded-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<div v-if="hasCustomContent" class="flex min-h-0 flex-1 flex-col">
<NodeContent
v-if="nodeMedia"
:node-data="nodeData"
:media="nodeMedia"
/>
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
:node-data="nodeData"
:media="preview"
<template v-if="!isCollapsed">
<div class="relative">
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
:class="
cn(
'absolute inset-x-0 top-1/2 -translate-y-1/2',
!!(progress < 1) && 'rounded-r-full',
progressClasses
)
"
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
/>
</div>
<!-- Live mid-execution preview images -->
<LivePreview
v-if="shouldShowPreviewImg"
:image-url="latestPreviewUrl"
/>
<NodeBadges v-bind="badges" :pricing="undefined" class="mt-auto" />
</div>
</template>
<div
v-if="
(hasAnyError && showErrorsTabEnabled) ||
lgraphNode?.isSubgraphNode() ||
showAdvancedState ||
showAdvancedInputsButton
"
:class="
cn(
'-z-1 flex h-7 w-full divide-x divide-component-node-border overflow-hidden rounded-t-none rounded-b-2xl text-xs',
!isCollapsed && '-mt-5 h-12'
)
"
>
<Button
v-if="lgraphNode?.isSubgraphNode()"
variant="textonly"
:class="
cn(
'h-full flex-1 rounded-none',
hasAnyError &&
showErrorsTabEnabled &&
!nodeData.color &&
'bg-node-component-header-surface',
isCollapsed ? 'py-2' : 'pt-7 pb-2'
)
"
data-testid="subgraph-enter-button"
@click.stop="handleEnterSubgraph"
>
<span class="truncate">{{
hasAnyError && showErrorsTabEnabled
? t('g.enter')
: t('g.enterSubgraph')
}}</span>
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</Button>
<Button
v-if="hasAnyError && showErrorsTabEnabled"
variant="textonly"
:class="
cn(
'h-full flex-1 rounded-none bg-error hover:bg-destructive-background-hover',
isCollapsed ? 'py-2' : 'pt-7 pb-2'
)
"
@click.stop="useRightSidePanelStore().openPanel('errors')"
>
<span class="truncate">{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4 shrink-0" />
</Button>
<div
:class="
cn(
'flex flex-1 flex-col gap-1 bg-component-node-background pt-1 pb-3',
bodyRoundingClass
)
"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />
<!-- Advanced inputs (non-subgraph nodes only) -->
<Button
v-if="
!lgraphNode?.isSubgraphNode() &&
(showAdvancedState || showAdvancedInputsButton)
"
variant="textonly"
:class="
cn('h-full flex-1 rounded-none', isCollapsed ? 'py-2' : 'pt-7 pb-2')
"
@click.stop="showAdvancedState = !showAdvancedState"
>
<template v-if="showAdvancedState">
<span class="truncate">{{
t('rightSidePanel.hideAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
</template>
<template v-else>
<span class="truncate">{{
t('rightSidePanel.showAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
</template>
</Button>
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<div v-if="hasCustomContent" class="flex min-h-0 flex-1 flex-col">
<NodeContent
v-if="nodeMedia"
:node-data="nodeData"
:media="nodeMedia"
/>
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
:node-data="nodeData"
:media="preview"
/>
</div>
<!-- Live mid-execution preview images -->
<LivePreview
v-if="shouldShowPreviewImg"
:image-url="latestPreviewUrl"
/>
<NodeBadges
v-if="!isTransparentHeaderless"
v-bind="badges"
:pricing="undefined"
class="mt-auto"
/>
</div>
</template>
</div>
<NodeFooter
:is-subgraph="!!lgraphNode?.isSubgraphNode()"
:has-any-error="hasAnyError"
:show-errors-tab-enabled="showErrorsTabEnabled"
:is-collapsed="isCollapsed"
:show-advanced-inputs-button="showAdvancedInputsButton"
:show-advanced-state="showAdvancedState"
:header-color="applyLightThemeColor(nodeData?.color)"
:shape="nodeData.shape"
@enter-subgraph="handleEnterSubgraph"
@open-errors="handleOpenErrors"
@toggle-advanced="handleToggleAdvanced"
/>
<template
v-if="!isCollapsed && nodeData.resizable !== false && !isSelectMode"
>
@@ -231,6 +213,8 @@
cn(
baseResizeHandleClasses,
handle.positionClasses,
(handle.corner === 'SE' || handle.corner === 'SW') &&
footerResizeHandleBottomClass,
handle.cursorClass,
'group-hover/node:opacity-100'
)
@@ -272,7 +256,6 @@ import {
} from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useAppMode } from '@/composables/useAppMode'
@@ -312,13 +295,13 @@ import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isTransparent } from '@/utils/colorUtil'
import { isVideoOutput } from '@/utils/litegraphUtil'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import { isTransparent } from '@/utils/colorUtil'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -328,6 +311,7 @@ import { useNodeResize } from '../interactions/resize/useNodeResize'
import LivePreview from './LivePreview.vue'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeFooter from './NodeFooter.vue'
import NodeSlots from './NodeSlots.vue'
import NodeWidgets from './NodeWidgets.vue'
@@ -565,53 +549,104 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
}
)
const borderClass = computed(() => {
if (hasAnyError.value) return 'border-node-stroke-error bg-error'
//FIXME need a better way to detecting transparency
if (
!displayHeader.value &&
nodeData.bgcolor &&
isTransparent(nodeData.bgcolor)
const hasFooter = computed(() => {
return !!(
(hasAnyError.value && showErrorsTabEnabled.value) ||
lgraphNode.value?.isSubgraphNode() ||
(!lgraphNode.value?.isSubgraphNode() &&
(showAdvancedState.value || showAdvancedInputsButton.value))
)
return 'border-0'
return ''
})
const outlineClass = computed(() => {
return cn(
isSelected.value && 'outline-node-component-outline',
hasAnyError.value && 'outline-node-stroke-error',
executing.value && 'outline-node-stroke-executing'
)
// Footer offset computed classes
const footerStateOutlineBottomClass = computed(() =>
hasFooter.value ? '-bottom-[35px]' : ''
)
const footerRootBorderBottomClass = computed(() =>
hasFooter.value ? '-bottom-8' : ''
)
const footerResizeHandleBottomClass = computed(() => {
if (!hasFooter.value) return ''
return hasAnyError.value ? 'bottom-[-31px]' : 'bottom-[-35px]'
})
const cursorClass = computed(() => {
return cn(
nodeData.flags?.pinned
? 'cursor-default'
: layoutStore.isDraggingVueNodes.value
? 'cursor-grabbing'
: 'cursor-grab'
)
if (nodeData.flags?.pinned) return 'cursor-default'
return layoutStore.isDraggingVueNodes.value
? 'cursor-grabbing'
: 'cursor-grab'
})
const bodyRoundingClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return 'rounded-br-2xl'
default:
return 'rounded-b-2xl'
}
})
const shapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return 'rounded-none'
return ''
case RenderShape.CARD:
return 'rounded-tl-2xl rounded-br-2xl rounded-tr-none rounded-bl-none'
return 'rounded-tl-2xl rounded-br-2xl'
default:
return 'rounded-2xl'
}
})
const isTransparentHeaderless = computed(
() =>
!displayHeader.value &&
!!nodeData.bgcolor &&
isTransparent(nodeData.bgcolor)
)
const rootBorderShapeClass = computed(() => {
if (isTransparentHeaderless.value) return 'border-0'
const isExpanded = hasAnyError.value
switch (nodeData.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isExpanded
? 'rounded-tl-[20px] rounded-br-[20px]'
: 'rounded-tl-2xl rounded-br-2xl'
default:
return isExpanded ? 'rounded-[20px]' : 'rounded-2xl'
}
})
const selectionShapeClass = computed(() => {
if (isTransparentHeaderless.value) return 'border-0'
const isExpanded = hasAnyError.value
switch (nodeData.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isExpanded
? 'rounded-tl-[23px] rounded-br-[23px]'
: 'rounded-tl-[19px] rounded-br-[19px]'
default:
return isExpanded ? 'rounded-[23px]' : 'rounded-[19px]'
}
})
const beforeShapeClass = computed(() => {
switch (nodeData.shape) {
case RenderShape.BOX:
return 'before:rounded-none'
return ''
case RenderShape.CARD:
return 'before:rounded-tl-2xl before:rounded-br-2xl before:rounded-tr-none before:rounded-bl-none'
return 'before:rounded-tl-2xl before:rounded-br-2xl'
default:
return 'before:rounded-2xl'
}
@@ -626,6 +661,16 @@ const handleHeaderTitleUpdate = (newTitle: string) => {
handleNodeTitleUpdate(nodeData.id, newTitle)
}
const rightSidePanelStore = useRightSidePanelStore()
const handleOpenErrors = () => {
rightSidePanelStore.openPanel('errors')
}
const handleToggleAdvanced = () => {
showAdvancedState.value = !showAdvancedState.value
}
const handleEnterSubgraph = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_node_open_subgraph_clicked'
@@ -705,7 +750,6 @@ const showAdvancedState = customRef((track, trigger) => {
if (node instanceof SubgraphNode) {
// Do not modify internalState for subgraph nodes
const rightSidePanelStore = useRightSidePanelStore()
if (value) {
rightSidePanelStore.focusSection('advanced-inputs')
} else {

View File

@@ -0,0 +1,183 @@
<template>
<!-- Case 1: Subgraph + Error (Dual Tabs) -->
<template v-if="isSubgraph && hasAnyError && showErrorsTabEnabled">
<Button
variant="textonly"
:class="
cn(
getTabStyles(false),
errorTabWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
)
"
@click.stop="$emit('openErrors')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4 shrink-0" />
</div>
</Button>
<Button
variant="textonly"
data-testid="subgraph-enter-button"
:class="
cn(
getTabStyles(true),
enterTabFullWidth,
'-z-10 bg-node-component-header-surface'
)
"
:style="{ backgroundColor: headerColor }"
@click.stop="$emit('enterSubgraph')"
>
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
<span class="truncate">{{ t('g.enter') }}</span>
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</div>
</Button>
</template>
<!-- Case 2: Error Only (Full Width) -->
<template v-else-if="hasAnyError && showErrorsTabEnabled">
<Button
variant="textonly"
:class="
cn(
getTabStyles(false),
enterTabFullWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
)
"
@click.stop="$emit('openErrors')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.error') }}</span>
<i class="icon-[lucide--info] size-4 shrink-0" />
</div>
</Button>
</template>
<!-- Case 3: Subgraph only (Full Width) -->
<template v-else-if="isSubgraph">
<Button
variant="textonly"
data-testid="subgraph-enter-button"
:class="
cn(
getTabStyles(true),
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
'-z-10 bg-node-component-header-surface'
)
"
:style="{ backgroundColor: headerColor }"
@click.stop="$emit('enterSubgraph')"
>
<div class="flex size-full items-center justify-center gap-2">
<span class="truncate">{{ t('g.enterSubgraph') }}</span>
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</div>
</Button>
</template>
<!-- Case 4: Advanced Footer (Regular Nodes) -->
<div
v-else-if="showAdvancedInputsButton || showAdvancedState"
class="relative -z-1 -mt-5 flex h-7 w-full divide-x divide-component-node-border overflow-hidden rounded-t-none rounded-b-2xl text-xs"
>
<Button
variant="textonly"
:class="
cn('h-full flex-1 rounded-none', isCollapsed ? 'py-2' : 'pt-7 pb-2')
"
@click.stop="$emit('toggleAdvanced')"
>
<template v-if="showAdvancedState">
<span class="truncate">{{
t('rightSidePanel.hideAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
</template>
<template v-else>
<span class="truncate">{{
t('rightSidePanel.showAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
</template>
</Button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { RenderShape } from '@/lib/litegraph/src/litegraph'
import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
interface Props {
isSubgraph: boolean
hasAnyError: boolean
showErrorsTabEnabled: boolean
isCollapsed: boolean
showAdvancedInputsButton?: boolean
showAdvancedState?: boolean
headerColor?: string
shape?: RenderShape
}
const props = defineProps<Props>()
defineEmits<{
(e: 'enterSubgraph'): void
(e: 'openErrors'): void
(e: 'toggleAdvanced'): void
}>()
const footerRadiusClass = computed(() => {
const isExpanded = props.hasAnyError
switch (props.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isExpanded ? 'rounded-br-[20px]' : 'rounded-br-2xl'
default:
return isExpanded ? 'rounded-b-[20px]' : 'rounded-b-2xl'
}
})
/**
* Returns shared size/position classes for footer tabs
* @param isBackground If true, calculates styles for the background/right tab (Enter Subgraph)
*/
const getTabStyles = (isBackground = false) => {
let sizeClasses = ''
if (props.isCollapsed) {
let pt = 'pt-10'
if (isBackground) {
pt = props.hasAnyError ? 'pt-10.5' : 'pt-9'
}
sizeClasses = cn('-mt-7.5 h-15', pt)
} else {
let pt = 'pt-12.5'
if (isBackground) {
pt = props.hasAnyError ? 'pt-12.5' : 'pt-11.5'
}
sizeClasses = cn('-mt-10 h-17.5', pt)
}
return cn(
'pointer-events-auto absolute top-full left-0 text-xs',
footerRadiusClass.value,
sizeClasses,
props.hasAnyError ? '-translate-x-1 translate-y-0.5' : 'translate-y-0.5'
)
}
// Case 1 context: Split widths
const errorTabWidth = 'w-[calc(50%+4px)]'
const enterTabFullWidth = 'w-[calc(100%+8px)]'
</script>

View File

@@ -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

View File

@@ -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,