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

@@ -2,6 +2,9 @@
import { ref, useTemplateRef } from 'vue'
import ZoomPane from '@/components/ui/ZoomPane.vue'
import { cn } from '@/utils/tailwindUtil'
defineOptions({ inheritAttrs: false })
const { src } = defineProps<{
src: string
@@ -13,7 +16,11 @@ const width = ref('')
const height = ref('')
</script>
<template>
<ZoomPane v-if="!mobile" v-slot="slotProps" class="w-full flex-1">
<ZoomPane
v-if="!mobile"
v-slot="slotProps"
:class="cn('w-full flex-1', $attrs.class as string)"
>
<img
ref="imageRef"
:src

View File

@@ -1,18 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import Button from '@/components/ui/button/Button.vue'
import { storeToRefs } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
import { useAppModeStore } from '@/stores/appModeStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
const { t } = useI18n()
const { setMode } = useAppMode()
const { hasOutputs } = storeToRefs(useAppModeStore())
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const nodeOutputStore = useNodeOutputStore()
const { nodeIdToNodeLocatorId } = useWorkflowStore()
const existingOutput = computed(() => {
for (const nodeId of appModeStore.selectedOutputs) {
const locatorId = nodeIdToNodeLocatorId(nodeId)
const nodeOutput = nodeOutputStore.nodeOutputs[locatorId]
if (!nodeOutput) continue
const results = flattenNodeOutput([nodeId, nodeOutput])
if (results.length > 0) return results[0]
}
return undefined
})
</script>
<template>
<MediaOutputPreview
v-if="existingOutput"
:output="existingOutput"
class="px-12 py-24"
/>
<div
v-if="hasOutputs"
v-else-if="hasOutputs"
role="article"
data-testid="arrange-preview"
class="mx-auto flex h-full w-3/4 flex-col items-center justify-center gap-6 p-8"
@@ -23,7 +48,7 @@ const { hasOutputs } = storeToRefs(useAppModeStore())
<p class="mb-0 font-bold text-base-foreground">
{{ t('linearMode.arrange.outputs') }}
</p>
<p>{{ t('linearMode.arrange.resultsLabel') }}</p>
<p class="text-center">{{ t('linearMode.arrange.resultsLabel') }}</p>
</div>
</div>
<div

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { defineAsyncComponent, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
@@ -15,23 +15,15 @@ import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
import LinearArrange from '@/renderer/extensions/linearMode/LinearArrange.vue'
import LinearFeedback from '@/renderer/extensions/linearMode/LinearFeedback.vue'
import MediaOutputPreview from '@/renderer/extensions/linearMode/MediaOutputPreview.vue'
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
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'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
// Lazy-loaded to avoid pulling THREE.js into the main bundle
const Preview3d = defineAsyncComponent(
() => import('@/renderer/extensions/linearMode/Preview3d.vue')
)
const { t } = useI18n()
const commandStore = useCommandStore()
@@ -73,17 +65,12 @@ async function loadWorkflow(item: AssetItem | undefined) {
const changeTracker = useWorkflowStore().activeWorkflow?.changeTracker
if (!changeTracker) return app.loadGraphData(workflow)
changeTracker.redoQueue = []
changeTracker.updateState([workflow], changeTracker.undoQueue)
await changeTracker.updateState([workflow], changeTracker.undoQueue)
}
async function rerun(e: Event) {
if (!runButtonClick) return
await loadWorkflow(selectedItem.value)
//FIXME don't use timeouts here
//Currently seeds fail to properly update even with timeouts?
await new Promise((r) => setTimeout(r, 500))
executeWidgetsCallback(collectAllNodes(app.rootGraph), 'afterQueued')
runButtonClick(e)
}
</script>
@@ -106,6 +93,7 @@ async function rerun(e: Event) {
</template>
<Button
v-if="selectedOutput"
v-tooltip.top="t('g.download')"
size="icon"
:aria-label="t('g.download')"
@click="
@@ -119,21 +107,26 @@ async function rerun(e: Event) {
<Button
v-if="!executionStore.isIdle && !selectedItem"
variant="destructive"
size="icon"
:aria-label="t('menu.interrupt')"
@click="commandStore.execute('Comfy.Interrupt')"
>
<i class="icon-[lucide--x]" />
{{ t('linearMode.cancelThisRun') }}
</Button>
<Popover
v-if="selectedItem"
:entries="[
{
icon: 'icon-[lucide--download]',
label: t('linearMode.downloadAll'),
command: () => downloadAsset(selectedItem)
},
{ separator: true },
...(allOutputs(selectedItem).length > 1
? [
{
icon: 'icon-[lucide--download]',
label: t('linearMode.downloadAll', {
count: allOutputs(selectedItem).length
}),
command: () => downloadAsset(selectedItem)
},
{ separator: true }
]
: []),
{
icon: 'icon-[lucide--trash-2]',
label: t('queue.jobMenu.deleteAsset'),
@@ -143,32 +136,14 @@ async function rerun(e: Event) {
/>
</section>
<ImagePreview
v-if="
(canShowPreview && latentPreview) ||
getMediaType(selectedOutput) === 'images'
"
v-if="canShowPreview && latentPreview"
:mobile
:src="(canShowPreview && latentPreview) || selectedOutput!.url"
:src="latentPreview"
/>
<VideoPreview
v-else-if="getMediaType(selectedOutput) === 'video'"
:src="selectedOutput!.url"
class="flex-1 object-contain md:p-3 md:contain-size"
/>
<audio
v-else-if="getMediaType(selectedOutput) === 'audio'"
class="m-auto w-full"
controls
:src="selectedOutput!.url"
/>
<article
v-else-if="getMediaType(selectedOutput) === 'text'"
class="m-auto my-12 w-full max-w-lg overflow-y-auto"
v-text="selectedOutput!.url"
/>
<Preview3d
v-else-if="getMediaType(selectedOutput) === '3d'"
:model-url="selectedOutput!.url"
<MediaOutputPreview
v-else-if="selectedOutput"
:output="selectedOutput"
:mobile
/>
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
<LinearArrange v-else-if="isArrangeMode" />

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { defineAsyncComponent, useAttrs } from 'vue'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
import type { ResultItemImpl } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
const Preview3d = defineAsyncComponent(
() => import('@/renderer/extensions/linearMode/Preview3d.vue')
)
defineOptions({ inheritAttrs: false })
const { output } = defineProps<{
output: ResultItemImpl
mobile?: boolean
}>()
const attrs = useAttrs()
</script>
<template>
<ImagePreview
v-if="getMediaType(output) === 'images'"
:class="attrs.class as string"
:mobile
:src="output.url"
/>
<VideoPreview
v-else-if="getMediaType(output) === 'video'"
:src="output.url"
:class="
cn('flex-1 object-contain md:p-3 md:contain-size', attrs.class as string)
"
/>
<audio
v-else-if="getMediaType(output) === 'audio'"
:class="cn('m-auto w-full', attrs.class as string)"
controls
:src="output.url"
/>
<article
v-else-if="getMediaType(output) === 'text'"
:class="
cn('m-auto my-12 w-full max-w-lg overflow-y-auto', attrs.class as string)
"
v-text="output.url"
/>
<Preview3d
v-else-if="getMediaType(output) === '3d'"
:class="attrs.class as string"
:model-url="output.url"
/>
</template>