feat/fix: App mode QA feedback 2 (#9511)

## Summary

Additional fixes and updates based on testing

## Changes

- **What**: 
- add warning to welcome screen & when sharing an app that has had all
outputs removed
- fix target workflow when changing mode via tab right click menu
- change build app text to be conditional "edit" vs "build" depending on
if an app is already defined
- update empty apps sidebar tab button text to make it clearer
- remove templates button from app mode (we will reintroduce this once
we have app templates)
- add "exit to graph" after applying default mode of node graph
- update cancel button to remove item from queue if it hasn't started
yet
- improve scoping of jobs/outputs to the current workflow [not perfect
but should be much improved]
- close sidebar tabs on entering app mode
- change tooltip to be under the workflow menu rather than covering the
button

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9511-feat-fix-App-mode-QA-feedback-2-31b6d73d365081d59bbbc13111100d46)
by [Unito](https://www.unito.io)
This commit is contained in:
pythongosssss
2026-03-07 02:57:03 +00:00
committed by GitHub
parent 8bfd93963f
commit 1058b7d12d
27 changed files with 471 additions and 107 deletions

View File

@@ -20,18 +20,13 @@ import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
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'
const { t } = useI18n()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const mediaActions = useMediaAssetActions()
const queueStore = useQueueStore()
const { isBuilderMode, isArrangeMode } = useAppMode()
const { allOutputs } = useOutputHistory()
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
useOutputHistory()
const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
runButtonClick?: (e: Event) => void
mobile?: boolean
@@ -42,12 +37,14 @@ const selectedItem = ref<AssetItem>()
const selectedOutput = ref<ResultItemImpl>()
const canShowPreview = ref(true)
const latentPreview = ref<string>()
const showSkeleton = ref(false)
function handleSelection(sel: OutputSelection) {
selectedItem.value = sel.asset
selectedOutput.value = sel.output
canShowPreview.value = sel.canShowPreview
latentPreview.value = sel.latentPreviewUrl
showSkeleton.value = sel.showSkeleton ?? false
}
function downloadAsset(item?: AssetItem) {
@@ -76,7 +73,7 @@ async function rerun(e: Event) {
</script>
<template>
<section
v-if="selectedItem || selectedOutput || !executionStore.isIdle"
v-if="selectedItem || selectedOutput || showSkeleton || isWorkflowActive"
data-testid="linear-output-info"
class="flex w-full flex-wrap justify-center gap-2 p-4 text-sm tabular-nums md:z-10"
>
@@ -105,9 +102,9 @@ async function rerun(e: Event) {
<i class="icon-[lucide--download]" />
</Button>
<Button
v-if="!executionStore.isIdle && !selectedItem"
v-if="isWorkflowActive && !selectedItem"
variant="destructive"
@click="commandStore.execute('Comfy.Interrupt')"
@click="cancelActiveWorkflowJobs()"
>
<i class="icon-[lucide--x]" />
{{ t('linearMode.cancelThisRun') }}
@@ -145,7 +142,7 @@ async function rerun(e: Event) {
:output="selectedOutput"
:mobile
/>
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
<LinearArrange v-else-if="isArrangeMode" />
<LinearWelcome v-else />
<div

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useQueueStore } from '@/stores/queueStore'
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
defineOptions({ inheritAttrs: false })
@@ -18,14 +18,14 @@ const {
}>()
const { totalPercent, currentNodePercent } = useQueueProgress()
const queueStore = useQueueStore()
const executionStore = useExecutionStore()
</script>
<template>
<div
:class="
cn(
'relative h-2 bg-secondary-background transition-opacity',
queueStore.runningTasks.length === 0 && 'opacity-0',
!executionStore.isActiveWorkflowRunning && 'opacity-0',
rounded && 'rounded-sm',
className
)

View File

@@ -4,12 +4,18 @@ import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useAppModeStore } from '@/stores/appModeStore'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
const { t } = useI18n()
const { setMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs, hasNodes } = storeToRefs(appModeStore)
const workflowStore = useWorkflowStore()
const isAppDefault = computed(
() => workflowStore.activeWorkflow?.initialMode === 'app'
)
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
</script>
@@ -47,6 +53,18 @@ const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
<p v-if="!hasNodes" class="mt-0 max-w-md text-sm text-base-foreground">
{{ t('linearMode.emptyWorkflowExplanation') }}
</p>
<p
v-if="hasNodes && isAppDefault"
class="mt-0 max-w-md text-sm text-base-foreground"
>
<i18n-t keypath="linearMode.welcome.noOutputs" tag="span">
<template #count>
<span class="font-bold text-warning-background">{{
t('linearMode.welcome.oneOutput')
}}</span>
</template>
</i18n-t>
</p>
<div class="flex flex-row gap-2">
<Button variant="textonly" size="lg" @click="setMode('graph')">
{{ t('linearMode.backToWorkflow') }}

View File

@@ -25,12 +25,15 @@ import type {
} from '@/renderer/extensions/linearMode/linearModeTypes'
import OutputPreviewItem from '@/renderer/extensions/linearMode/OutputPreviewItem.vue'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useQueueStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
const { outputs, allOutputs, selectFirstHistory } = useOutputHistory()
const { outputs, allOutputs, selectFirstHistory, mayBeActiveWorkflowPending } =
useOutputHistory()
const queueStore = useQueueStore()
const store = useLinearOutputStore()
const workflowStore = useWorkflowStore()
const emit = defineEmits<{
updateSelection: [selection: OutputSelection]
@@ -55,10 +58,7 @@ const visibleHistory = computed(() =>
const selectableItems = computed(() => {
const items: SelectionValue[] = []
if (
queueCount.value > 0 &&
store.activeWorkflowInProgressItems.length === 0
) {
if (mayBeActiveWorkflowPending.value) {
items.push({ id: 'slot:pending', kind: 'inProgress', itemId: 'pending' })
}
for (const item of store.activeWorkflowInProgressItems) {
@@ -120,7 +120,7 @@ function doEmit() {
(i) => i.id === sel.itemId
)
if (!item || item.state === 'skeleton') {
emit('updateSelection', { canShowPreview: true })
emit('updateSelection', { canShowPreview: true, showSkeleton: true })
} else if (item.state === 'latent') {
emit('updateSelection', {
canShowPreview: true,
@@ -146,6 +146,23 @@ function doEmit() {
watchEffect(doEmit)
// On load or workflow tab switch, select the most recent item.
// Prefer in-progress items for this workflow, then history, skipping
// the global pending slot which may belong to another workflow.
watch(
() => workflowStore.activeWorkflow?.path,
(path) => {
if (!path) return
const inProgress = store.activeWorkflowInProgressItems
if (inProgress.length > 0) {
store.selectAsLatest(`slot:${inProgress[0].id}`)
} else {
selectFirstHistory()
}
},
{ immediate: true }
)
// Keep history selection stable on media changes
watch(
() => outputs.media.value,
@@ -303,9 +320,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
/>
<div
v-if="
queueCount > 0 && store.activeWorkflowInProgressItems.length === 0
"
v-if="mayBeActiveWorkflowPending"
:ref="selectedRef('slot:pending')"
v-bind="itemAttrs('slot:pending')"
:class="itemClass"

View File

@@ -14,6 +14,7 @@ export interface OutputSelection {
output?: ResultItemImpl
canShowPreview: boolean
latentPreviewUrl?: string
showSkeleton?: boolean
}
export type SelectionValue =

View File

@@ -124,6 +124,7 @@ describe('linearOutputStore', () => {
it('auto-selects skeleton on first job start when no selection', () => {
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
expect(store.selectedId).toBe(`slot:${store.inProgressItems[0].id}`)
@@ -132,6 +133,7 @@ describe('linearOutputStore', () => {
it('transitions to latent on preview', () => {
vi.useFakeTimers()
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
const itemId = store.inProgressItems[0].id
@@ -265,6 +267,7 @@ describe('linearOutputStore', () => {
// selectAsLatest simulates "following the latest output"
store.selectAsLatest('history:asset-1:0')
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
// Following latest → auto-select new skeleton
@@ -286,6 +289,7 @@ describe('linearOutputStore', () => {
it('falls back selection when selected item is removed', () => {
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
const firstId = `slot:${store.inProgressItems[0].id}`
expect(store.selectedId).toBe(firstId)
@@ -400,6 +404,8 @@ describe('linearOutputStore', () => {
it('two sequential runs: selection clears after each resolve', () => {
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
// Run 1: 3 outputs
store.onJobStart('job-1')
@@ -738,6 +744,34 @@ describe('linearOutputStore', () => {
expect(imageItems[0].output?.nodeId).toBe('2')
})
it('does not auto-select for jobs belonging to another workflow', () => {
const store = useLinearOutputStore()
// User is on workflow-b, following latest
activeWorkflowPathRef.value = 'workflows/app-b.json'
store.selectAsLatest('history:asset-b:0')
// Job from workflow-a starts
setJobWorkflowPath('job-1', 'workflows/app-a.json')
store.onJobStart('job-1')
// Should NOT yank selection to the other workflow's slot
expect(store.selectedId).toBe('history:asset-b:0')
})
it('auto-selects for jobs belonging to the active workflow', () => {
const store = useLinearOutputStore()
activeWorkflowPathRef.value = 'workflows/app-a.json'
store.selectAsLatest('history:asset-a:0')
setJobWorkflowPath('job-1', 'workflows/app-a.json')
store.onJobStart('job-1')
// Should auto-select since job matches active workflow
expect(store.selectedId?.startsWith('slot:')).toBe(true)
})
it('ignores execution events when not in app mode', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()

View File

@@ -67,7 +67,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
inProgressItems.value = [item, ...inProgressItems.value]
trackedJobId.value = jobId
autoSelect(`slot:${item.id}`)
autoSelect(`slot:${item.id}`, jobId)
}
let raf: number | null = null
@@ -88,7 +88,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
state: 'latent',
latentPreviewUrl: url
}))
if (wasEmpty) autoSelect(`slot:${existing.id}`)
if (wasEmpty) autoSelect(`slot:${existing.id}`, jobId)
return
}
@@ -103,7 +103,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
}
currentSkeletonId.value = item.id
inProgressItems.value = [item, ...inProgressItems.value]
autoSelect(`slot:${item.id}`)
autoSelect(`slot:${item.id}`, jobId)
})
}
@@ -136,7 +136,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
output: newOutputs[0],
latentPreviewUrl: undefined
}
autoSelect(`slot:${imageItem.id}`)
autoSelect(`slot:${imageItem.id}`, jobId)
const extras: InProgressItem[] = newOutputs.slice(1).map((o) => ({
id: makeItemId(jobId),
@@ -162,7 +162,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
state: 'image' as const,
output: o
}))
autoSelect(`slot:${newItems[0].id}`)
autoSelect(`slot:${newItems[0].id}`, jobId)
inProgressItems.value = [...newItems, ...inProgressItems.value]
}
@@ -226,7 +226,12 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
isFollowing.value = true
}
function autoSelect(slotId: string) {
function autoSelect(slotId: string, jobId: string) {
// Only auto-select if the job belongs to the active workflow
const path = workflowStore.activeWorkflow?.path
if (path && executionStore.jobIdToSessionWorkflowPath.get(jobId) !== path)
return
const sel = selectedId.value
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
selectedId.value = slotId

View File

@@ -11,9 +11,13 @@ import { ResultItemImpl } from '@/stores/queueStore'
const mediaRef = ref<AssetItem[]>([])
const pendingResolveRef = ref(new Set<string>())
const inProgressItemsRef = ref<InProgressItem[]>([])
const activeWorkflowInProgressItemsRef = ref<InProgressItem[]>([])
const selectedIdRef = ref<string | null>(null)
const activeWorkflowPathRef = ref<string>('workflows/test.json')
const jobIdToPathRef = ref(new Map<string, string>())
const isActiveWorkflowRunningRef = ref(false)
const runningTasksRef = ref<Array<{ jobId: string }>>([])
const pendingTasksRef = ref<Array<{ jobId: string }>>([])
const selectAsLatestFn = vi.fn()
const resolveIfReadyFn = vi.fn()
@@ -40,6 +44,9 @@ vi.mock('@/renderer/extensions/linearMode/linearOutputStore', () => ({
get inProgressItems() {
return inProgressItemsRef.value
},
get activeWorkflowInProgressItems() {
return activeWorkflowInProgressItemsRef.value
},
get selectedId() {
return selectedIdRef.value
},
@@ -61,10 +68,27 @@ vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
get jobIdToSessionWorkflowPath() {
return jobIdToPathRef.value
},
get isActiveWorkflowRunning() {
return isActiveWorkflowRunningRef.value
}
})
}))
vi.mock('@/stores/queueStore', async (importOriginal) => {
return {
...(await importOriginal()),
useQueueStore: () => ({
get runningTasks() {
return runningTasksRef.value
},
get pendingTasks() {
return pendingTasksRef.value
}
})
}
})
const { jobDetailResults } = vi.hoisted(() => ({
jobDetailResults: new Map<string, unknown>()
}))
@@ -128,9 +152,13 @@ describe(useOutputHistory, () => {
mediaRef.value = []
pendingResolveRef.value = new Set()
inProgressItemsRef.value = []
activeWorkflowInProgressItemsRef.value = []
selectedIdRef.value = null
activeWorkflowPathRef.value = 'workflows/test.json'
jobIdToPathRef.value = new Map()
isActiveWorkflowRunningRef.value = false
runningTasksRef.value = []
pendingTasksRef.value = []
resolvedOutputsCacheRef.clear()
jobDetailResults.clear()
selectAsLatestFn.mockReset()
@@ -378,4 +406,54 @@ describe(useOutputHistory, () => {
expect(selectAsLatestFn).toHaveBeenCalledWith(null)
})
})
describe('mayBeActiveWorkflowPending', () => {
it('returns false when no tasks are queued', () => {
const { mayBeActiveWorkflowPending } = useOutputHistory()
expect(mayBeActiveWorkflowPending.value).toBe(false)
})
it('returns false when there are active in-progress items', () => {
activeWorkflowInProgressItemsRef.value = [
{ id: 'item-1', jobId: 'job-1', state: 'skeleton' }
]
runningTasksRef.value = [{ jobId: 'job-1' }]
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
const { mayBeActiveWorkflowPending } = useOutputHistory()
expect(mayBeActiveWorkflowPending.value).toBe(false)
})
it('returns true when a running task matches the active workflow', () => {
runningTasksRef.value = [{ jobId: 'job-1' }]
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
const { mayBeActiveWorkflowPending } = useOutputHistory()
expect(mayBeActiveWorkflowPending.value).toBe(true)
})
it('returns true when a pending task matches the active workflow', () => {
pendingTasksRef.value = [{ jobId: 'job-1' }]
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
const { mayBeActiveWorkflowPending } = useOutputHistory()
expect(mayBeActiveWorkflowPending.value).toBe(true)
})
it('returns false when tasks belong to another workflow', () => {
runningTasksRef.value = [{ jobId: 'job-1' }]
jobIdToPathRef.value = new Map([['job-1', 'workflows/other.json']])
const { mayBeActiveWorkflowPending } = useOutputHistory()
expect(mayBeActiveWorkflowPending.value).toBe(false)
})
it('returns false when no workflow path is set', () => {
activeWorkflowPathRef.value = ''
runningTasksRef.value = [{ jobId: 'job-1' }]
const { mayBeActiveWorkflowPending } = useOutputHistory()
expect(mayBeActiveWorkflowPending.value).toBe(false)
})
})
})

View File

@@ -1,4 +1,5 @@
import { useAsyncState } from '@vueuse/core'
import type { ComputedRef } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
@@ -9,14 +10,20 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
import { getJobDetail } from '@/services/jobOutputCache'
import { api } from '@/scripts/api'
import { useAppModeStore } from '@/stores/appModeStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/stores/queueStore'
export function useOutputHistory(): {
outputs: IAssetsProvider
allOutputs: (item?: AssetItem) => ResultItemImpl[]
selectFirstHistory: () => void
mayBeActiveWorkflowPending: ComputedRef<boolean>
isWorkflowActive: ComputedRef<boolean>
cancelActiveWorkflowJobs: () => Promise<void>
} {
const backingOutputs = useMediaAssets('output')
void backingOutputs.fetchMediaList()
@@ -24,6 +31,37 @@ export function useOutputHistory(): {
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
const appModeStore = useAppModeStore()
const queueStore = useQueueStore()
function matchesActiveWorkflow(task: { jobId: string | number }): boolean {
const path = workflowStore.activeWorkflow?.path
if (!path) return false
return (
executionStore.jobIdToSessionWorkflowPath.get(String(task.jobId)) === path
)
}
function hasActiveWorkflowJobs(): boolean {
if (!workflowStore.activeWorkflow?.path) return false
return (
queueStore.runningTasks.some(matchesActiveWorkflow) ||
queueStore.pendingTasks.some(matchesActiveWorkflow)
)
}
// True when there are queued/running jobs for the active workflow but no
// in-progress output items yet.
const mayBeActiveWorkflowPending = computed(() => {
if (linearStore.activeWorkflowInProgressItems.length > 0) return false
return hasActiveWorkflowJobs()
})
// True when the active workflow has running/pending jobs or in-progress items.
const isWorkflowActive = computed(
() =>
linearStore.activeWorkflowInProgressItems.length > 0 ||
hasActiveWorkflowJobs()
)
function filterByOutputNodes(items: ResultItemImpl[]): ResultItemImpl[] {
const nodeIds = appModeStore.selectedOutputs
@@ -140,5 +178,29 @@ export function useOutputHistory(): {
}
})
return { outputs, allOutputs, selectFirstHistory }
async function cancelActiveWorkflowJobs() {
if (!workflowStore.activeWorkflow?.path) return
// Interrupt the running job if it belongs to this workflow
if (queueStore.runningTasks.some(matchesActiveWorkflow)) {
void useCommandStore().execute('Comfy.Interrupt')
} else {
// Delete first pending job for this workflow from the queue
for (const task of queueStore.pendingTasks) {
if (matchesActiveWorkflow(task)) {
await api.deleteItem('queue', String(task.jobId))
break
}
}
}
}
return {
outputs,
allOutputs,
selectFirstHistory,
mayBeActiveWorkflowPending,
isWorkflowActive,
cancelActiveWorkflowJobs
}
}