Yet further app fixes (#9523)

- Prevent selection of note nodes
- Prevents selection  of nodes with errors
- A bit broader than the reported "can select missing nodes". A node
with an error is a node that can not execute and thus can not be used in
an app.
- Updates the typeform survey
- Add a collapsible list of all api nodes(/prices) contained in an app.
  - Needs to be prettied up for mobile still.
<img width="322" height="751" alt="image"
src="https://github.com/user-attachments/assets/ebfeeada-9b80-488e-88d6-feaa8bd53629"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9523-Yet-further-app-fixes-31c6d73d365081de9150fbf2d3ec54dd)
by [Unito](https://www.unito.io)
This commit is contained in:
AustinMroz
2026-03-07 14:22:15 -08:00
committed by GitHub
parent 2875f897dc
commit 83ffaf30c8
11 changed files with 180 additions and 57 deletions

View File

@@ -27,7 +27,7 @@ import { app } from '@/scripts/app'
import { DOMWidgetImpl } from '@/scripts/domWidget'
import { useDialogService } from '@/services/dialogService'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNode } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
@@ -162,7 +162,11 @@ function handleDown(e: MouseEvent) {
}
function handleClick(e: MouseEvent) {
const [node, widget] = getHovered(e) ?? []
if (node?.mode !== LGraphEventMode.ALWAYS)
if (
node?.mode !== LGraphEventMode.ALWAYS ||
!nodeTypeValidForApp(node.type) ||
node.has_errors
)
return canvasInteractions.forwardEventToCanvas(e)
if (!widget) {
@@ -198,7 +202,9 @@ const renderedOutputs = computed(() => {
return canvas
.graph!.nodes.filter(
(n) =>
n.constructor.nodeData?.output_node && n.mode === LGraphEventMode.ALWAYS
n.constructor.nodeData?.output_node &&
n.mode === LGraphEventMode.ALWAYS &&
!n.has_errors
)
.map(nodeToDisplayTuple)
})

View File

@@ -3179,6 +3179,7 @@
"loadTemplate": "Load a template",
"cancelThisRun": "Cancel this run",
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",

View File

@@ -25,7 +25,7 @@ function togglePromotion() {
<div
:class="
cn(
'pointer-events-auto absolute size-full rounded-2xl ring-5 ring-warning-background/50',
'pointer-events-auto absolute z-1 size-full rounded-2xl ring-5 ring-warning-background/50',
isPromoted && 'ring-warning-background'
)
"

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useEventListener, useTimeout } from '@vueuse/core'
import { partition, remove, takeWhile } from 'es-toolkit'
import { remove, takeWhile } from 'es-toolkit'
import { storeToRefs } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -19,6 +19,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -119,20 +120,6 @@ function nodeToNodeData(node: LGraphNode) {
onDragOver: node.onDragOver
}
}
const partitionedNodes = computed(() => {
const parts = partition(
graphNodes.value
.filter((node) => node.mode === 0 && node.widgets?.length)
.map(nodeToNodeData)
.reverse(),
(node) => ['MarkdownNote', 'Note'].includes(node.type)
)
for (const noteNode of parts[0]) {
for (const widget of noteNode.widgets ?? [])
widget.options = { ...widget.options, read_only: true }
}
return parts
})
//TODO: refactor out of this file.
//code length is small, but changes should propagate
@@ -180,34 +167,6 @@ defineExpose({ runButtonClick })
v-text="workflowStore.activeWorkflow?.filename"
/>
<div class="flex-1" />
<Popover
v-if="partitionedNodes[0].length"
align="end"
class="z-100 max-h-(--reka-popover-content-available-height) overflow-x-clip overflow-y-auto"
side="bottom"
:side-offset="-8"
>
<template #button>
<Button variant="muted-textonly">
<i class="icon-[lucide--info]" />
</Button>
</template>
<div>
<template
v-for="(nodeData, index) in partitionedNodes[0]"
:key="nodeData.id"
>
<div
v-if="index !== 0"
class="w-full border-t border-border-subtle"
/>
<NodeWidgets
:node-data
class="max-w-100 gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1"
/>
</template>
</div>
</Popover>
<Button v-if="false"> {{ t('menuLabels.publish') }} </Button>
</section>
<div
@@ -218,9 +177,7 @@ defineExpose({ runButtonClick })
class="grow overflow-y-auto contain-size"
>
<template
v-for="(nodeData, index) of appModeStore.selectedInputs.length
? mappedSelections
: partitionedNodes[0]"
v-for="(nodeData, index) of mappedSelections"
:key="nodeData.id"
>
<div
@@ -274,6 +231,7 @@ defineExpose({ runButtonClick })
</template>
</div>
</Teleport>
<PartnerNodesList v-if="!mobile" />
<section
v-if="mobile"
data-testid="linear-run-button"
@@ -284,6 +242,7 @@ defineExpose({ runButtonClick })
class="mt-4 w-full"
/>
<div v-else class="mt-4 flex">
<PartnerNodesList mobile />
<Popover side="top" @open-auto-focus.prevent>
<template #button>
<Button size="lg" class="-mr-3 pr-7">

View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
defineProps<{ title: string; price: string }>()
</script>
<template>
<div class="not-last:mb-4">
<div class="text-muted-foreground" v-text="title" />
<span
class="mt-2 flex h-5 max-w-max items-center rounded-full bg-component-node-widget-background p-2 py-3"
>
<i class="mr-1 icon-[lucide--component] h-4 bg-amber-400" />
{{ price }}
</span>
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script setup lang="ts">
import {
CollapsibleContent,
CollapsibleRoot,
CollapsibleTrigger
} from 'reka-ui'
import { computed, toValue } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import PartnerNodeItem from '@/renderer/extensions/linearMode/PartnerNodeItem.vue'
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { app } from '@/scripts/app'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
defineProps<{ mobile?: boolean }>()
const { isCreditsBadge } = usePriceBadge()
const { t } = useI18n()
const creditsBadges = computed(() =>
mapAllNodes(app.graph, (node) => {
if (node.isSubgraphNode()) return
const priceBadge = node.badges.find(isCreditsBadge)
if (!priceBadge) return
trackNodePrice(node)
return [node.title, toValue(priceBadge).text, node.id] as const
})
)
</script>
<template>
<Popover v-if="mobile && creditsBadges.length" side="top">
<template #button>
<Button class="mr-2 size-10">
<i class="icon-[comfy--credits] bg-amber-400" />
</Button>
</template>
<section
class="max-h-(--reka-popover-content-available-height) overflow-y-auto"
>
<PartnerNodeItem
v-for="[title, price, key] in creditsBadges"
:key
:title
:price
/>
</section>
</Popover>
<div v-else-if="creditsBadges.length === 1">
<PartnerNodeItem
v-for="[title, price, key] in creditsBadges"
:key
:title
:price
class="border-t border-border-subtle pt-2"
/>
</div>
<CollapsibleRoot
v-else-if="creditsBadges.length"
v-slot="{ open }"
class="flex max-h-1/2 w-full flex-col"
>
<div class="mb-1 w-full border-b border-border-subtle" />
<CollapsibleTrigger as-child>
<Button variant="textonly" class="w-full text-sm">
<i class="icon-[comfy--credits] size-4 bg-amber-400" />
{{ t('linearMode.hasCreditCost') }}
<i v-if="open" class="ml-auto icon-[lucide--chevron-up]" />
<i v-else class="ml-auto icon-[lucide--chevron-down]" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="overflow-y-auto">
<PartnerNodeItem
v-for="[title, price, key] in creditsBadges"
:key
:title
:price
/>
</CollapsibleContent>
</CollapsibleRoot>
</template>

View File

@@ -39,7 +39,8 @@
v-if="
lgraphNode?.constructor?.nodeData?.output_node &&
isSelectOutputsMode &&
nodeData.mode === LGraphEventMode.ALWAYS
nodeData.mode === LGraphEventMode.ALWAYS &&
!nodeData.hasErrors
"
:id="nodeData.id"
/>
@@ -91,10 +92,6 @@
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"

View File

@@ -109,6 +109,7 @@ import {
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import {
stripGraphPrefix,
useWidgetValueStore
@@ -160,7 +161,11 @@ onErrorCaptured((error) => {
})
const canSelectInputs = computed(
() => isSelectInputsMode.value && nodeData?.mode === LGraphEventMode.ALWAYS
() =>
isSelectInputsMode.value &&
nodeData?.mode === LGraphEventMode.ALWAYS &&
nodeTypeValidForApp(nodeData.type) &&
!nodeData.hasErrors
)
const nodeType = computed(() => nodeData?.type || '')
const settingStore = useSettingStore()

View File

@@ -4,6 +4,8 @@ import { computed, toValue } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { usePriceBadge } from '@/composables/node/usePriceBadge'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
import { app } from '@/scripts/app'
@@ -17,6 +19,56 @@ function splitAroundFirstSpace(text: string): [string, string | undefined] {
return [text.slice(0, index), text.slice(index + 1)]
}
type TrackableNode = {
id: NodeId
type: string
inputs?: INodeInputSlot[]
}
//TODO deduplicate reactivity tracking once more thoroughly tested
export function trackNodePrice(node: TrackableNode) {
const {
getRelevantWidgetNames,
hasDynamicPricing,
getInputGroupPrefixes,
getInputNames,
getNodeRevisionRef
} = useNodePricing()
// Access per-node revision ref to establish dependency (each node has its own ref)
void getNodeRevisionRef(node.id).value
if (!hasDynamicPricing(node.type)) return
// Access only the widget values that affect pricing (from widgetValueStore)
const relevantNames = getRelevantWidgetNames(node.type)
const widgetStore = useWidgetValueStore()
const graphId = app.canvas?.graph?.rootGraph.id
if (relevantNames.length > 0 && node.id != null) {
for (const name of relevantNames) {
// Access value from store to create reactive dependency
if (!graphId) continue
void widgetStore.getWidget(graphId, node.id, name)?.value
}
}
// Access input connections for regular inputs
const inputNames = getInputNames(node.type)
if (inputNames.length > 0) {
node?.inputs?.forEach((inp) => {
if (inp.name && inputNames.includes(inp.name)) {
void inp.link // Access link to create reactive dependency
}
})
}
// Access input connections for input_groups (e.g., autogrow inputs)
const groupPrefixes = getInputGroupPrefixes(node.type)
if (groupPrefixes.length > 0) {
node?.inputs?.forEach((inp) => {
if (groupPrefixes.some((prefix) => inp.name?.startsWith(prefix + '.'))) {
void inp.link // Access link to create reactive dependency
}
})
}
}
export function usePartitionedBadges(nodeData: VueNodeData) {
// Use per-node pricing revision to re-compute badges only when this node's pricing updates
const {

View File

@@ -12,6 +12,10 @@ import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { resolveNode } from '@/utils/litegraphUtil'
export function nodeTypeValidForApp(type: string) {
return !['Note', 'MarkdownNote'].includes(type)
}
export const useAppModeStore = defineStore('appMode', () => {
const { getCanvas } = useCanvasStore()
const workflowStore = useWorkflowStore()

View File

@@ -85,7 +85,7 @@ const { onResizeEnd } = useStablePrimeVueSplitterSizer(
[activeTab, splitterKey]
)
const TYPEFORM_WIDGET_ID = 'gmVqFi8l'
const TYPEFORM_WIDGET_ID = 'jmmzmlKw'
const bottomLeftRef = useTemplateRef('bottomLeftRef')
const bottomRightRef = useTemplateRef('bottomRightRef')