Split selection into an inputs and outputs step (#9362)

When building an app, selecting inputs and selecting outputs are now 2
separate steps. This prevents confusion where clicking on the widget of
an output node will select that widget instead of the entire output.

<img width="1673" height="773" alt="image"
src="https://github.com/user-attachments/assets/e5994479-6fcf-4572-b58b-bf8cecfb7d55"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9362-Split-selection-into-an-inputs-and-outputs-step-3196d73d36508187b4a1e51c73f1c54c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
AustinMroz
2026-03-04 15:18:16 -08:00
committed by GitHub
parent 316a05c77f
commit 57a919fad2
15 changed files with 147 additions and 115 deletions

View File

@@ -39,7 +39,8 @@ const workflowStore = useWorkflowStore()
const { t } = useI18n()
const canvas: LGraphCanvas = canvasStore.getCanvas()
const { isSelectMode, isArrangeMode } = useAppMode()
const { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
useAppMode()
const hoveringSelectable = ref(false)
provide(HideLayoutFieldKey, true)
@@ -161,6 +162,7 @@ function handleClick(e: MouseEvent) {
if (!node) return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
if (!isSelectOutputsMode.value) return
if (!node.constructor.nodeData?.output_node)
return canvasInteractions.forwardEventToCanvas(e)
const index = appModeStore.selectedOutputs.findIndex((id) => id == node.id)
@@ -168,6 +170,7 @@ function handleClick(e: MouseEvent) {
else appModeStore.selectedOutputs.splice(index, 1)
return
}
if (!isSelectInputsMode.value) return
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => node.id == nodeId && widget.name === widgetName
@@ -234,7 +237,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</div>
</DraggableList>
<PropertiesAccordionItem
v-else
v-if="isSelectInputsMode"
:label="t('nodeHelpPage.inputs')"
enable-empty-state
:disabled="!appModeStore.selectedInputs.length"
@@ -283,7 +286,7 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="!isArrangeMode"
v-if="isSelectOutputsMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
@@ -344,42 +347,46 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
@wheel="canvasInteractions.forwardEventToCanvas"
>
<TransformPane :canvas="canvasStore.getCanvas()">
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k == key)
"
@pointerdown.stop
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
<template v-if="isSelectInputsMode">
<div
v-for="[key, style] in renderedInputs"
:key
:style="toValue(style)"
class="fixed bg-primary-background/30 rounded-lg"
/>
</template>
<template v-else>
<div
v-for="[key, style, isSelected] in renderedOutputs"
:key
:style="toValue(style)"
:class="
cn(
'fixed ring-warning-background ring-5 rounded-2xl',
!isSelected && 'ring-warning-background/50'
)
"
>
<div class="absolute top-0 right-0 size-8">
<div
v-if="isSelected"
class="absolute -top-1/2 -right-1/2 size-full p-2 bg-warning-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="
remove(appModeStore.selectedOutputs, (k) => k == key)
"
@pointerdown.stop
>
<i class="icon-[lucide--check] bg-text-foreground size-full" />
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="appModeStore.selectedOutputs.push(key)"
@pointerdown.stop
/>
</div>
<div
v-else
class="absolute -top-1/2 -right-1/2 size-full ring-warning-background/50 ring-4 ring-inset bg-component-node-background rounded-lg cursor-pointer pointer-events-auto"
@click.stop="appModeStore.selectedOutputs.push(key)"
@pointerdown.stop
/>
</div>
</div>
</template>
</TransformPane>
</div>
</Teleport>

View File

@@ -63,7 +63,7 @@ describe('BuilderFooterToolbar', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockState.mode = 'builder:select'
mockState.mode = 'builder:inputs'
mockHasOutputs.value = true
mockState.settingView = false
})
@@ -87,7 +87,7 @@ describe('BuilderFooterToolbar', () => {
}
it('disables back on the first step', () => {
mockState.mode = 'builder:select'
mockState.mode = 'builder:inputs'
const { back } = getButtons(mountComponent())
expect(back.attributes('disabled')).toBeDefined()
})
@@ -111,8 +111,8 @@ describe('BuilderFooterToolbar', () => {
expect(next.attributes('disabled')).toBeDefined()
})
it('enables next on select step', () => {
mockState.mode = 'builder:select'
it('enables next on inputs step', () => {
mockState.mode = 'builder:inputs'
const { next } = getButtons(mountComponent())
expect(next.attributes('disabled')).toBeUndefined()
})
@@ -121,14 +121,14 @@ describe('BuilderFooterToolbar', () => {
mockState.mode = 'builder:arrange'
const { back } = getButtons(mountComponent())
await back.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:select')
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
it('calls setMode on next click from select step', async () => {
mockState.mode = 'builder:select'
it('calls setMode on next click from inputs step', async () => {
mockState.mode = 'builder:inputs'
const { next } = getButtons(mountComponent())
await next.trigger('click')
expect(mockSetMode).toHaveBeenCalledWith('builder:arrange')
expect(mockSetMode).toHaveBeenCalledWith('builder:outputs')
})
it('opens default view dialog on next click from arrange step', async () => {

View File

@@ -6,17 +6,14 @@
<div
class="inline-flex items-center gap-1 rounded-2xl border border-border-default bg-base-background p-2 shadow-interface"
>
<template
v-for="(step, index) in [selectStep, arrangeStep]"
:key="step.id"
>
<template v-for="(step, index) in steps" :key="step.id">
<button
:class="
cn(
stepClasses,
activeStep === step.id && 'bg-interface-builder-mode-background',
activeStep !== step.id &&
'hover:bg-secondary-background bg-transparent'
activeStep === step.id
? 'bg-interface-builder-mode-background'
: 'hover:bg-secondary-background bg-transparent'
)
"
:aria-current="activeStep === step.id ? 'step' : undefined"
@@ -32,13 +29,13 @@
<!-- Default view -->
<ConnectOutputPopover
v-if="!hasOutputs"
:is-select-active="activeStep === 'builder:select'"
@switch="navigateToStep('builder:select')"
:is-select-active="isSelectStep"
@switch="navigateToStep('builder:outputs')"
>
<button :class="cn(stepClasses, 'opacity-30 bg-transparent')">
<StepBadge
:step="defaultViewStep"
:index="2"
:index="steps.length"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
@@ -58,7 +55,7 @@
>
<StepBadge
:step="defaultViewStep"
:index="2"
:index="steps.length"
:model-value="activeStep"
/>
<StepLabel :step="defaultViewStep" />
@@ -84,15 +81,22 @@ import { useBuilderSteps } from './useBuilderSteps'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { activeStep, navigateToStep } = useBuilderSteps()
const { activeStep, isSelectStep, navigateToStep } = useBuilderSteps()
const stepClasses =
'inline-flex h-14 min-h-8 cursor-pointer items-center gap-3 rounded-lg py-2 pr-4 pl-2 transition-colors border-none'
const selectStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:select',
title: t('builderToolbar.select'),
subtitle: t('builderToolbar.selectDescription'),
const selectInputsStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:inputs',
title: t('builderToolbar.inputs'),
subtitle: t('builderToolbar.inputsDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
const selectOutputsStep: BuilderToolbarStep<BuilderStepId> = {
id: 'builder:outputs',
title: t('builderToolbar.outputs'),
subtitle: t('builderToolbar.outputsDescription'),
icon: 'icon-[lucide--mouse-pointer-click]'
}
@@ -109,4 +113,5 @@ const defaultViewStep: BuilderToolbarStep<BuilderStepId> = {
subtitle: t('builderToolbar.defaultViewDescription'),
icon: 'icon-[lucide--eye]'
}
const steps = [selectInputsStep, selectOutputsStep, arrangeStep]
</script>

View File

@@ -46,7 +46,7 @@
</PopoverClose>
<PopoverClose as-child>
<Button variant="secondary" size="md" @click="emit('switch')">
{{ t('builderToolbar.switchToSelect') }}
{{ t('builderToolbar.switchToOutputs') }}
</Button>
</PopoverClose>
</template>

View File

@@ -7,7 +7,8 @@ import { useAppMode } from '@/composables/useAppMode'
import { useAppSetDefaultView } from './useAppSetDefaultView'
const BUILDER_STEPS = [
'builder:select',
'builder:inputs',
'builder:outputs',
'builder:arrange',
'setDefaultView'
] as const
@@ -25,7 +26,7 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
if (isBuilderMode.value) {
return mode.value as BuilderStepId
}
return 'builder:select'
return 'builder:inputs'
})
const activeStepIndex = computed(() =>
@@ -40,6 +41,12 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
return activeStepIndex.value >= BUILDER_STEPS.length - 1
})
const isSelectStep = computed(
() =>
activeStep.value === 'builder:inputs' ||
activeStep.value === 'builder:outputs'
)
function navigateToStep(stepId: BuilderStepId) {
if (stepId === 'setDefaultView') {
setMode('builder:arrange')
@@ -64,6 +71,7 @@ export function useBuilderSteps(options?: { hasOutputs?: Ref<boolean> }) {
activeStepIndex,
isFirstStep,
isLastStep,
isSelectStep,
navigateToStep,
goBack,
goNext

View File

@@ -39,8 +39,8 @@
<BottomPanel />
</template>
<template v-if="showUI" #right-side-panel>
<AppBuilder v-if="mode === 'builder:select'" />
<NodePropertiesPanel v-else-if="!isBuilderMode" />
<AppBuilder v-if="isBuilderMode" />
<NodePropertiesPanel v-else />
</template>
<template #graph-canvas-panel>
<GraphCanvasMenu
@@ -204,7 +204,7 @@ const nodeSearchboxPopoverRef = shallowRef<InstanceType<
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const { mode, isBuilderMode } = useAppMode()
const { isBuilderMode } = useAppMode()
const canvasStore = useCanvasStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()

View File

@@ -2,7 +2,12 @@ import { computed, ref } from 'vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
export type AppMode = 'graph' | 'app' | 'builder:select' | 'builder:arrange'
export type AppMode =
| 'graph'
| 'app'
| 'builder:inputs'
| 'builder:outputs'
| 'builder:arrange'
const enableAppBuilder = ref(true)
@@ -18,13 +23,17 @@ export function useAppMode() {
const isBuilderMode = computed(
() => isSelectMode.value || isArrangeMode.value
)
const isSelectMode = computed(() => mode.value === 'builder:select')
const isSelectInputsMode = computed(() => mode.value === 'builder:inputs')
const isSelectOutputsMode = computed(() => mode.value === 'builder:outputs')
const isSelectMode = computed(
() => isSelectInputsMode.value || isSelectOutputsMode.value
)
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isGraphMode = computed(
() => mode.value === 'graph' || mode.value === 'builder:select'
() => mode.value === 'graph' || isSelectMode.value
)
function setMode(newMode: AppMode) {
@@ -39,6 +48,8 @@ export function useAppMode() {
enableAppBuilder,
isBuilderMode,
isSelectMode,
isSelectInputsMode,
isSelectOutputsMode,
isArrangeMode,
isAppMode,
isGraphMode,

View File

@@ -3051,11 +3051,11 @@
},
"arrange": {
"noOutputs": "No outputs added yet",
"switchToSelect": "Switch to the 'Select' step and click on output nodes to add them here.",
"switchToOutputs": "Switch to the 'Output' step and click on output nodes to add them here.",
"connectAtLeastOne": "Connect {atLeastOne} output node so users can see results after running.",
"atLeastOne": "at least one",
"outputExamples": "Examples: 'Save Image' or 'Save Video'",
"switchToSelectButton": "Switch to Select",
"switchToOutputsButton": "Switch to Outputs",
"outputs": "Outputs",
"resultsLabel": "Results generated from the selected output node(s) will be shown here after running this app"
},
@@ -3359,16 +3359,18 @@
},
"builderToolbar": {
"label": "App Builder",
"select": "Select",
"selectDescription": "Choose inputs/outputs",
"inputs": "Inputs",
"outputs": "Outputs",
"inputsDescription": "Choose inputs",
"outputsDescription": "Choose outputs",
"arrange": "Preview",
"arrangeDescription": "Review app layout",
"defaultView": "Set a default view",
"defaultViewDescription": "Choose how this opens",
"connectOutput": "Connect an output",
"connectOutputBody1": "Your app needs at least one output to be connected before it can be saved.",
"connectOutputBody2": "Switch to the 'Select' step and click on output nodes to add them here.",
"switchToSelect": "Switch to Select",
"connectOutputBody2": "Switch to the 'Output' step and click on output nodes to add them here.",
"switchToOutputs": "Switch to Outputs",
"defaultViewTitle": "Set the default view for this workflow",
"defaultViewLabel": "By default, this workflow will open as:",
"app": "App",

View File

@@ -365,14 +365,14 @@ describe('useWorkflowService', () => {
})
const workflow2 = createModeTestWorkflow({
path: 'workflows/two.json',
activeMode: 'builder:select'
activeMode: 'builder:inputs'
})
workflowStore.activeWorkflow = workflow1
expect(appMode.mode.value).toBe('app')
workflowStore.activeWorkflow = workflow2
expect(appMode.mode.value).toBe('builder:select')
expect(appMode.mode.value).toBe('builder:inputs')
})
})
@@ -507,7 +507,7 @@ describe('useWorkflowService', () => {
it('each workflow retains its own mode across tab switches', () => {
const workflow1 = createModeTestWorkflow({
path: 'workflows/one.json',
activeMode: 'builder:select'
activeMode: 'builder:inputs'
})
const workflow2 = createModeTestWorkflow({
path: 'workflows/two.json',
@@ -515,13 +515,13 @@ describe('useWorkflowService', () => {
})
workflowStore.activeWorkflow = workflow1
expect(appMode.mode.value).toBe('builder:select')
expect(appMode.mode.value).toBe('builder:inputs')
workflowStore.activeWorkflow = workflow2
expect(appMode.mode.value).toBe('app')
workflowStore.activeWorkflow = workflow1
expect(appMode.mode.value).toBe('builder:select')
expect(appMode.mode.value).toBe('builder:inputs')
})
})
})

View File

@@ -8,7 +8,7 @@ import { cn } from '@/utils/tailwindUtil'
const { id, name } = defineProps<{
id: string
isSelectMode: boolean
isSelectInputsMode: boolean
name: string
}>()
@@ -25,7 +25,7 @@ function togglePromotion() {
</script>
<template>
<div
v-if="isSelectMode"
v-if="isSelectInputsMode"
class="col-span-2 flex flex-row pointer-events-auto cursor-pointer gap-1 relative"
@pointerdown.capture.stop.prevent="togglePromotion"
@click.capture.stop.prevent

View File

@@ -37,7 +37,7 @@ const { hasOutputs } = storeToRefs(useAppModeStore())
</p>
<div class="flex flex-col gap-1 text-muted-foreground w-lg text-[14px]">
<p class="mt-0 p-0">{{ t('linearMode.arrange.switchToSelect') }}</p>
<p class="mt-0 p-0">{{ t('linearMode.arrange.switchToOutputs') }}</p>
<i18n-t keypath="linearMode.arrange.connectAtLeastOne" tag="div">
<template #atLeastOne>
@@ -50,8 +50,8 @@ const { hasOutputs } = storeToRefs(useAppModeStore())
<p class="mt-0 p-0">{{ t('linearMode.arrange.outputExamples') }}</p>
</div>
<div class="flex flex-row gap-2">
<Button variant="primary" size="lg" @click="setMode('builder:select')">
{{ t('linearMode.arrange.switchToSelectButton') }}
<Button variant="primary" size="lg" @click="setMode('builder:outputs')">
{{ t('linearMode.arrange.switchToOutputsButton') }}
</Button>
</div>
</div>

View File

@@ -51,7 +51,9 @@
@drop.stop.prevent="handleDrop"
>
<AppOutput
v-if="lgraphNode?.constructor?.nodeData?.output_node && isSelectMode"
v-if="
lgraphNode?.constructor?.nodeData?.output_node && isSelectOutputsMode
"
:id="nodeData.id"
/>
<div
@@ -337,7 +339,7 @@ const { nodeData, error = null } = defineProps<LGraphNodeProps>()
const { t } = useI18n()
const { isSelectMode } = useAppMode()
const { isSelectMode, isSelectOutputsMode } = useAppMode()
const settingStore = useSettingStore()
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeRightClick } =

View File

@@ -53,7 +53,7 @@
/>
</div>
<!-- Widget Component -->
<AppInput :id="widget.id" :name="widget.name" :is-select-mode>
<AppInput :id="widget.id" :name="widget.name" :is-select-inputs-mode>
<component
:is="widget.vueComponent"
v-model="widget.value"
@@ -123,7 +123,7 @@ const { nodeData } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
const { isSelectMode } = useAppMode()
const { isSelectInputsMode } = useAppMode()
const canvasStore = useCanvasStore()
const { bringNodeToFront } = useNodeZIndex()
const promotionStore = usePromotionStore()

View File

@@ -50,7 +50,7 @@ vi.mock('@/components/builder/useEmptyWorkflowDialog', () => ({
import { useAppModeStore } from './appModeStore'
function createBuilderWorkflow(
activeMode: string = 'builder:select'
activeMode: string = 'builder:inputs'
): LoadedComfyWorkflow {
const workflow = new ComfyWorkflowClass({
path: 'workflows/test.json',
@@ -88,29 +88,29 @@ describe('appModeStore', () => {
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:arrange')
})
it('navigates to builder:select when in app mode without outputs', () => {
it('navigates to builder:inputs when in app mode without outputs', () => {
workflowStore.activeWorkflow = createBuilderWorkflow('app')
store.enterBuilder()
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:select')
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:inputs')
})
it('navigates to builder:select when in graph mode with outputs', () => {
it('navigates to builder:inputs when in graph mode with outputs', () => {
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.selectedOutputs.push(1)
store.enterBuilder()
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:select')
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:inputs')
})
it('navigates to builder:select when in graph mode without outputs', () => {
it('navigates to builder:inputs when in graph mode without outputs', () => {
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
store.enterBuilder()
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:select')
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:inputs')
})
it('shows empty workflow dialog when graph has no nodes', () => {
@@ -141,7 +141,7 @@ describe('appModeStore', () => {
const options = getDialogOptions()
// Move to builder so onDismiss must actually transition back
workflowStore.activeWorkflow!.activeMode = 'builder:select'
workflowStore.activeWorkflow!.activeMode = 'builder:inputs'
options.onDismiss()
@@ -156,7 +156,7 @@ describe('appModeStore', () => {
options.onEnterBuilder()
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:select')
expect(workflowStore.activeWorkflow!.activeMode).toBe('builder:inputs')
})
it('onEnterBuilder shows dialog again when no nodes', () => {

View File

@@ -13,7 +13,7 @@ import { resolveNode } from '@/utils/litegraphUtil'
export const useAppModeStore = defineStore('appMode', () => {
const { getCanvas } = useCanvasStore()
const workflowStore = useWorkflowStore()
const { mode, setMode, isBuilderMode } = useAppMode()
const { mode, setMode, isBuilderMode, isSelectMode } = useAppMode()
const emptyWorkflowDialog = useEmptyWorkflowDialog()
const selectedInputs = reactive<[NodeId, string][]>([])
@@ -78,20 +78,17 @@ export const useAppModeStore = defineStore('appMode', () => {
)
let unwatch: () => void | undefined
watch(
() => mode.value === 'builder:select',
(inSelect) => {
const { state } = getCanvas()
if (!state) return
state.readOnly = inSelect
unwatch?.()
if (inSelect)
unwatch = watch(
() => state.readOnly,
() => (state.readOnly = true)
)
}
)
watch(isSelectMode, (inSelect) => {
const { state } = getCanvas()
if (!state) return
state.readOnly = inSelect
unwatch?.()
if (inSelect)
unwatch = watch(
() => state.readOnly,
() => (state.readOnly = true)
)
})
function enterBuilder() {
if (!app.rootGraph?.nodes?.length) {
@@ -105,7 +102,7 @@ export const useAppModeStore = defineStore('appMode', () => {
setMode(
mode.value === 'app' && hasOutputs.value
? 'builder:arrange'
: 'builder:select'
: 'builder:inputs'
)
}