refactor: Unify app builder & app widget lists (#9829)

## Summary

Currently app builder & app mode use slightly different rendering paths
for the widgets, giving a different preview to what you actually get,
this changes it to use the same path for both.

## Changes

- **What**: 
- Extract LinearControls widget rendering
- Replace app builder arrange step with this
- Add ability to rename/remove widgets during app mode

## Screenshots (if applicable)

<img width="1205" height="853" alt="image"
src="https://github.com/user-attachments/assets/9160e33f-08c7-4863-a62d-c03929ffd3c8"
/>
<img width="1246" height="843" alt="image"
src="https://github.com/user-attachments/assets/82d8bacd-fd32-4ad7-b09d-eaa6670590ef"
/>

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9829-refactor-Unify-app-builder-app-widget-lists-3216d73d36508157a888f08dbe3655ea)
by [Unito](https://www.unito.io)
This commit is contained in:
pythongosssss
2026-03-13 17:01:46 +00:00
committed by GitHub
parent 5640eb7d92
commit f1626acb61
4 changed files with 237 additions and 168 deletions

View File

@@ -1,13 +1,13 @@
<script setup lang="ts">
import { remove } from 'es-toolkit'
import { computed, provide, ref, toValue } from 'vue'
import { computed, ref, toValue } from 'vue'
import type { MaybeRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import WidgetItem from '@/components/rightSidePanel/parameters/WidgetItem.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
@@ -30,7 +30,6 @@ import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
type BoundStyle = { top: string; left: string; width: string; height: string }
@@ -46,19 +45,8 @@ const { isSelectMode, isSelectInputsMode, isSelectOutputsMode, isArrangeMode } =
useAppMode()
const hoveringSelectable = ref(false)
provide(HideLayoutFieldKey, true)
workflowStore.activeWorkflow?.changeTracker?.reset()
const arrangeInputs = computed(() =>
appModeStore.selectedInputs
.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return node ? { nodeId, widgetName, node, widget } : null
})
.filter((item): item is NonNullable<typeof item> => item !== null)
)
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
@@ -201,7 +189,9 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
</script>
<template>
<div class="flex h-full flex-col">
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
<div
class="flex h-12 items-center border-b border-border-subtle px-4 font-bold"
>
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
@@ -209,34 +199,10 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
class="overflow-x-clip"
>
<div
v-for="{ nodeId, widgetName, node, widget } in arrangeInputs"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'pointer-events-auto my-2 p-2')"
:aria-label="`${widget?.label ?? widgetName} ${node.title}`"
>
<div v-if="widget" class="pointer-events-none" inert>
<WidgetItem
:widget="widget"
:node="node"
show-node-name
hidden-widget-actions
/>
</div>
<div
v-else
class="pointer-events-none p-1 text-sm text-muted-foreground"
>
{{ widgetName }}
<p class="text-xs italic">
({{ t('linearMode.builder.unknownWidget') }})
</p>
</div>
</div>
<AppModeWidgetList builder-mode />
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, provide, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@/utils/tailwindUtil'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
}
action: { widget: IBaseWidget; node: LGraphNode }
}
const { mobile = false, builderMode = false } = defineProps<{
mobile?: boolean
builderMode?: boolean
}>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
provide(HideLayoutFieldKey, true)
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(
app.rootGraph.events,
'configured',
() => (graphNodes.value = app.rootGraph.nodes)
)
const mappedSelections = computed((): WidgetEntry[] => {
void graphNodes.value
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
}
const fullNodeData = nodeDataByNode.get(node)!
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return (
isPromotedWidgetView(widget) &&
widget.sourceNodeId == storeNodeId &&
widget.sourceWidgetName === vueWidget.storeName
)
})
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = String(node.id)
return [
{
key: `${nodeId}:${widgetName}`,
nodeData: {
...fullNodeData,
widgets: [matchingWidget]
},
action: { widget, node }
}
]
})
})
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const filename = node.widgets?.[0]?.value
const resultItem = { type: 'input', filename: `${filename}` }
const buildImageUrl = () => {
if (!filename) return undefined
const params = new URLSearchParams(resultItem)
appendCloudResParam(params, resultItem.filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
}
return {
iconClass: 'icon-[lucide--image]',
imageUrl: buildImageUrl(),
label: mobile ? undefined : t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined)
}
}
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
return {
...nodeData,
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
dropIndicator,
onDragDrop: node.onDragDrop,
onDragOver: node.onDragOver
}
}
</script>
<template>
<div
v-for="{ key, nodeData, action } in mappedSelections"
:key
:class="
cn(
builderMode &&
'draggable-item drag-handle pointer-events-auto relative cursor-grab [&.is-draggable]:cursor-grabbing'
)
"
:aria-label="
builderMode
? `${action.widget.label ?? action.widget.name} ${action.node.title}`
: undefined
"
>
<div
:class="
cn(
'mt-1.5 flex min-h-8 items-center gap-1 px-3',
builderMode && 'drag-handle'
)
"
>
<span
:class="cn('truncate text-sm/8', builderMode && 'pointer-events-none')"
>
{{ action.widget.label || action.widget.name }}
</span>
<span
v-if="builderMode"
class="pointer-events-none mx-1 min-w-10 flex-1 truncate text-right text-xs text-muted-foreground"
>
{{ action.node.title }}
</span>
<div v-else class="flex-1" />
<Popover
:class="cn('shrink-0', builderMode && 'pointer-events-auto')"
:entries="[
{
label: t('g.rename'),
icon: 'icon-[lucide--pencil]',
command: () => promptRenameWidget(action.widget, action.node, t)
},
{
label: t('g.remove'),
icon: 'icon-[lucide--x]',
command: () =>
appModeStore.removeSelectedInput(action.widget, action.node)
}
]"
>
<template #button>
<Button variant="textonly" size="icon">
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>
</Popover>
</div>
<div
:class="builderMode && 'pointer-events-none'"
:inert="builderMode || undefined"
>
<DropZone
:on-drag-over="nodeData.onDragOver"
:on-drag-drop="nodeData.onDragDrop"
:drop-indicator="nodeData.dropIndicator"
class="text-muted-foreground"
>
<NodeWidgets
:node-data
:class="
cn(
'gap-y-3 rounded-lg py-1 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
)
"
/>
</DropZone>
</div>
</div>
</template>

View File

@@ -1,39 +1,26 @@
<script setup lang="ts">
import { useEventListener, useTimeout } from '@vueuse/core'
import { remove, takeWhile } from 'es-toolkit'
import { useTimeout } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import Loader from '@/components/loader/Loader.vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
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'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
const { t } = useI18n()
const commandStore = useCommandStore()
const executionErrorStore = useExecutionErrorStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
const settingStore = useSettingStore()
const { isActiveSubscription } = useBillingContext()
@@ -42,7 +29,7 @@ const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const props = defineProps<{
const { toastTo, mobile } = defineProps<{
toastTo?: string | HTMLElement
mobile?: boolean
}>()
@@ -56,91 +43,6 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
{ controls: true, immediate: false }
)
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(
app.rootGraph.events,
'configured',
() => (graphNodes.value = app.rootGraph.nodes)
)
const mappedSelections = computed(() => {
void graphNodes.value
let unprocessedInputs = appModeStore.selectedInputs.flatMap(
([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
return widget ? ([[node, widget]] as const) : []
}
)
const processedInputs: ReturnType<typeof nodeToNodeData>[] = []
while (unprocessedInputs.length) {
const [node] = unprocessedInputs[0]
const inputGroup = takeWhile(unprocessedInputs, ([n]) => n === node).map(
([, widget]) => widget
)
unprocessedInputs = unprocessedInputs.slice(inputGroup.length)
//FIXME: hide widget if owning node bypassed
if (node?.mode !== LGraphEventMode.ALWAYS) continue
const nodeData = nodeToNodeData(node)
remove(nodeData.widgets ?? [], (vueWidget) => {
if (vueWidget.slotMetadata?.linked) return true
if (!node.isSubgraphNode())
return !inputGroup.some((w) => w.name === vueWidget.name)
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return !inputGroup.some(
(subWidget) =>
isPromotedWidgetView(subWidget) &&
subWidget.sourceNodeId == storeNodeId &&
subWidget.sourceWidgetName === vueWidget.storeName
)
})
for (const widget of nodeData.widgets ?? []) {
widget.slotMetadata = undefined
widget.nodeId = String(node.id)
}
processedInputs.push(nodeData)
}
return processedInputs
})
function getDropIndicator(node: LGraphNode) {
if (node.type !== 'LoadImage') return undefined
const filename = node.widgets?.[0]?.value
const resultItem = { type: 'input', filename: `${filename}` }
const buildImageUrl = () => {
if (!filename) return undefined
const params = new URLSearchParams(resultItem)
appendCloudResParam(params, resultItem.filename)
return api.apiURL(`/view?${params}${app.getPreviewFormatParam()}`)
}
return {
iconClass: 'icon-[lucide--image]',
imageUrl: buildImageUrl(),
label: props.mobile ? undefined : t('linearMode.dragAndDropImage'),
onClick: () => node.widgets?.[1]?.callback?.(undefined)
}
}
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
const nodeData = extractVueNodeData(node)
return {
...nodeData,
//note lastNodeErrors uses exeuctionid, node.id is execution for root
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
dropIndicator,
onDragDrop: node.onDragDrop,
onDragOver: node.onDragOver
}
}
//TODO: refactor out of this file.
//code length is small, but changes should propagate
async function runButtonClick(e: Event) {
@@ -196,32 +98,7 @@ defineExpose({ runButtonClick })
data-testid="linear-widgets"
class="grow overflow-y-auto contain-size"
>
<template
v-for="(nodeData, index) of mappedSelections"
:key="nodeData.id"
>
<div
v-if="index !== 0 && !appModeStore.selectedInputs.length"
class="w-full border-t border-node-component-border"
/>
<DropZone
:on-drag-over="nodeData.onDragOver"
:on-drag-drop="nodeData.onDragDrop"
:drop-indicator="nodeData.dropIndicator"
class="text-muted-foreground"
>
<NodeWidgets
:node-data
:class="
cn(
'gap-y-3 rounded-lg py-3 *:has-[textarea]:h-50 **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
nodeData.hasErrors &&
'ring-2 ring-node-stroke-error ring-inset'
)
"
/>
</DropZone>
</template>
<AppModeWidgetList :mobile />
</section>
<Teleport
v-if="!jobToastTimeout || pendingJobQueues > 0"

View File

@@ -11,6 +11,8 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { app } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { resolveNode } from '@/utils/litegraphUtil'
export function nodeTypeValidForApp(type: string) {
@@ -137,12 +139,24 @@ export const useAppModeStore = defineStore('appMode', () => {
setMode('graph')
}
function removeSelectedInput(widget: IBaseWidget, node: { id: NodeId }) {
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
const index = selectedInputs.findIndex(
([id, name]) => storeId == id && storeName === name
)
if (index !== -1) selectedInputs.splice(index, 1)
}
return {
enterBuilder,
exitBuilder,
hasNodes,
hasOutputs,
pruneLinearData,
removeSelectedInput,
resetSelectedToWorkflow,
selectedInputs,
selectedOutputs