feat/fix: App mode QA updates (#9439)

## Summary

Various fixes from app mode QA

## Changes

- **What**: 
- fix: prevent inserting nodes from workflow/apps sidebar tabs
- fix: hide json extension in workflow tab
- fix: hide apps nav button in apps tab when already in apps mode
- fix: center text on arrange page
- fix: prevent IoItems from "jumping" due to stale transform after drag
and drop op
- fix: refactor side panels and add custom stable pixel based sizing
- fix: make outputs/inputs lists in app builder scrollable
- fix: fix rerun not working correctly

- feat: add text to interrupt button
- feat: add enter app mode button to builder toolbar
- feat: add tooltip to download button on linear view
- feat: show last output of workflow in arrange tab if available
- feat: show download count in download all button, hide if only 1 asset
to download

## Review Focus

- Rerun - I am not sure why it was triggering widget actions, removing
it seemed like the correct fix
- useStablePrimeVueSplitter - this is a workaround for the fact it uses
percent sizing, I also tried switching to reka-ui splitters, but they
also only support % sizing in our version [pixel based looks to have
been added in a newer version, will log an issue to upgrade & replace
splitters with this]


## Screenshots (if applicable)

<img width="1314" height="1129" alt="image"
src="https://github.com/user-attachments/assets/c430f9d6-7c29-4853-803e-5b6fe7086fca"
/>
<img width="511" height="283" alt="image"
src="https://github.com/user-attachments/assets/b7e594d4-70a1-41e3-8ba1-78512f2a5c8b"
/>
<img width="254" height="232" alt="image"
src="https://github.com/user-attachments/assets/1d146399-39ea-4b0e-928c-340b74957535"
/>
<img width="487" height="198" alt="image"
src="https://github.com/user-attachments/assets/e2ba7f5d-8ff5-47f4-9526-61ebb99514b8"
/>
<img width="378" height="647" alt="image"
src="https://github.com/user-attachments/assets/a47a3054-9320-4327-bdc0-b0a16e19f83d"
/>
<img width="1016" height="476" alt="image"
src="https://github.com/user-attachments/assets/479ae50e-d380-4d56-a5c9-5df142b14ed0"
/>


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9439-feat-fix-App-mode-QA-updates-31a6d73d365081b38337d63207b88817)
by [Unito](https://www.unito.io)
This commit is contained in:
pythongosssss
2026-03-06 20:02:19 +00:00
committed by GitHub
parent bae1081a08
commit 4ff14b5eb9
16 changed files with 626 additions and 322 deletions

View File

@@ -204,131 +204,145 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
)
</script>
<template>
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
</div>
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<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 class="flex h-full flex-col">
<div class="flex items-center border-b border-border-subtle p-2 font-bold">
{{
isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title')
}}
</div>
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"
: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"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 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">
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="isSelectOutputsMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 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"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'my-2 rounded-lg bg-warning-background/40 p-2',
index === 0 && 'ring-2 ring-warning-background'
)
"
:title
:sub-title="String(key)"
:remove="() => remove(appModeStore.selectedOutputs, (k) => k == key)"
/>
</DraggableList>
</PropertiesAccordionItem>
<div class="min-h-0 flex-1 overflow-y-auto">
<DraggableList
v-if="isArrangeMode"
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<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>
</DraggableList>
<PropertiesAccordionItem
v-if="isSelectInputsMode"
: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"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.inputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 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"
>
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
rename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="label ?? widgetName"
:sub-title="subLabel"
:rename
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
/>
</DraggableList>
</PropertiesAccordionItem>
<PropertiesAccordionItem
v-if="isSelectOutputsMode"
:label="t('nodeHelpPage.outputs')"
enable-empty-state
:disabled="!appModeStore.selectedOutputs.length"
:tooltip="`${t('linearMode.builder.outputsDesc')}\n${t('linearMode.builder.outputsExample')}`"
:tooltip-delay="100"
>
<template #label>
<div class="flex gap-3">
{{ t('nodeHelpPage.outputs') }}
<i class="icon-[lucide--circle-alert] bg-muted-foreground" />
</div>
</template>
<template #empty>
<div
class="w-full p-4 pt-2 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"
>
<IoItem
v-for="([key, title], index) in outputsWithState"
:key
:class="
cn(
dragClass,
'my-2 rounded-lg bg-warning-background/40 p-2',
index === 0 && 'ring-2 ring-warning-background'
)
"
:title
:sub-title="String(key)"
:remove="
() => remove(appModeStore.selectedOutputs, (k) => k == key)
"
/>
</DraggableList>
</PropertiesAccordionItem>
</div>
</div>
<Teleport
v-if="isSelectMode && !settingStore.get('Comfy.VueNodes.Enabled')"

View File

@@ -19,38 +19,31 @@
</button>
</template>
<template #default="{ close }">
<button
:class="
cn(
'flex w-full items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-sm',
hasOutputs
? 'cursor-pointer hover:bg-secondary-background-hover'
: 'pointer-events-none opacity-50'
)
"
:disabled="!hasOutputs"
@click="onSave(close)"
>
<i class="icon-[lucide--save] size-4" />
{{ t('g.save') }}
</button>
<div class="my-1 border-t border-border-default" />
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-md border-none bg-transparent px-3 py-2 text-sm hover:bg-secondary-background-hover"
@click="onExitBuilder(close)"
>
<i class="icon-[lucide--square-pen] size-4" />
{{ t('builderMenu.exitAppBuilder') }}
</button>
<template v-for="(item, index) in menuItems" :key="item.label">
<div v-if="index > 0" class="my-1 border-t border-border-default" />
<Button
variant="textonly"
size="unset"
class="flex w-full items-center justify-start gap-3 rounded-md px-3 py-2 text-sm"
:disabled="item.disabled"
@click="item.action(close)"
>
<i :class="cn(item.icon, 'size-4')" />
{{ item.label }}
</Button>
</template>
</template>
</Popover>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -60,10 +53,30 @@ import { cn } from '@/utils/tailwindUtil'
const { t } = useI18n()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { setMode } = useAppMode()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const { toastErrorHandler } = useErrorHandling()
const menuItems = computed(() => [
{
label: t('g.save'),
icon: 'icon-[lucide--save]',
disabled: !hasOutputs.value,
action: onSave
},
{
label: t('builderMenu.enterAppMode'),
icon: 'icon-[lucide--panels-top-left]',
action: onEnterAppMode
},
{
label: t('builderMenu.exitAppBuilder'),
icon: 'icon-[lucide--square-pen]',
action: onExitBuilder
}
])
async function onSave(close: () => void) {
const workflow = workflowStore.activeWorkflow
if (!workflow) return
@@ -75,6 +88,11 @@ async function onSave(close: () => void) {
}
}
function onEnterAppMode(close: () => void) {
setMode('app')
close()
}
function onExitBuilder(close: () => void) {
void appModeStore.exitBuilder()
close()

View File

@@ -2,7 +2,6 @@
<BaseWorkflowsSidebarTab
:title="$t('linearMode.appModeToolbar.apps')"
:filter="isAppWorkflow"
:label-transform="stripAppJsonSuffix"
hide-leaf-icon
:search-subject="$t('linearMode.appModeToolbar.apps')"
data-testid="apps-sidebar"
@@ -18,8 +17,14 @@
<NoResultsPlaceholder
button-variant="secondary"
text-class="text-muted-foreground text-sm"
:message="$t('linearMode.appModeToolbar.appsEmptyMessage')"
:button-label="$t('linearMode.appModeToolbar.enterAppMode')"
:message="
isAppMode
? $t('linearMode.appModeToolbar.appsEmptyMessage')
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
"
:button-label="
isAppMode ? undefined : $t('linearMode.appModeToolbar.enterAppMode')
"
@action="enterAppMode"
/>
</template>
@@ -32,16 +37,12 @@ import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSide
import { useAppMode } from '@/composables/useAppMode'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
const { setMode } = useAppMode()
const { isAppMode, setMode } = useAppMode()
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
return workflow.suffix === 'app.json'
}
function stripAppJsonSuffix(label: string): string {
return label.replace(/\.app\.json$/i, '')
}
function enterAppMode() {
setMode('app')
}

View File

@@ -154,6 +154,7 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useAppMode } from '@/composables/useAppMode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import {
@@ -163,26 +164,23 @@ import {
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { TreeExplorerNode, TreeNode } from '@/types/treeExplorerTypes'
import { ensureWorkflowSuffix, getWorkflowSuffix } from '@/utils/formatUtil'
import {
ensureWorkflowSuffix,
getFilenameDetails,
getWorkflowSuffix
} from '@/utils/formatUtil'
import { buildTree, sortedTree } from '@/utils/treeUtil'
const {
title,
filter,
searchSubject,
dataTestid,
labelTransform,
hideLeafIcon
} = defineProps<{
const { title, filter, searchSubject, dataTestid, hideLeafIcon } = defineProps<{
title: string
filter?: (workflow: ComfyWorkflow) => boolean
searchSubject: string
dataTestid: string
labelTransform?: (label: string) => string
hideLeafIcon?: boolean
}>()
const { t } = useI18n()
const { isAppMode } = useAppMode()
const applyFilter = (workflows: ComfyWorkflow[]) =>
filter ? workflows.filter(filter) : workflows
@@ -304,14 +302,18 @@ const renderTreeNode = (
},
contextMenuItems() {
return [
{
label: t('g.insert'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.insertWorkflow(workflow)
}
},
...(isAppMode.value
? []
: [
{
label: t('g.insert'),
icon: 'pi pi-file-export',
command: async () => {
const workflow = node.data
await workflowService.insertWorkflow(workflow)
}
}
]),
{
label: t('g.duplicate'),
icon: 'pi pi-file-export',
@@ -326,8 +328,7 @@ const renderTreeNode = (
}
: { handleClick }
const label =
node.leaf && labelTransform ? labelTransform(node.label) : node.label
const label = node.leaf ? getFilenameDetails(node.label).filename : node.label
return {
key: node.key,