From 1058b7d12dd69407f89c453a8e41e8d3ec5a3c25 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Sat, 7 Mar 2026 02:57:03 +0000 Subject: [PATCH] feat/fix: App mode QA feedback 2 (#9511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- src/components/appMode/AppModeToolbar.vue | 18 ----- src/components/builder/AppBuilder.vue | 30 ++++--- ...BuilderDefaultModeAppliedDialogContent.vue | 5 +- .../builder/BuilderFooterToolbar.vue | 2 +- src/components/builder/BuilderMenu.vue | 2 +- .../builder/useAppSetDefaultView.test.ts | 18 +++++ .../builder/useAppSetDefaultView.ts | 6 ++ .../common/NoResultsPlaceholder.vue | 2 + .../common/WorkflowActionsDropdown.vue | 14 +++- src/components/dialog/confirm/ConfirmBody.vue | 5 +- .../dialog/confirm/ConfirmFooter.vue | 12 ++- .../sidebar/tabs/AppsSidebarTab.vue | 5 +- .../useWorkflowActionsMenu.test.ts | 48 +++++++++--- src/composables/useWorkflowActionsMenu.ts | 34 ++++++-- src/locales/en/main.json | 23 ++++-- .../sharing/composables/useShareDialog.ts | 45 ++++++++++- .../extensions/linearMode/LinearPreview.vue | 19 ++--- .../linearMode/LinearProgressBar.vue | 6 +- .../extensions/linearMode/LinearWelcome.vue | 18 +++++ .../extensions/linearMode/OutputHistory.vue | 33 +++++--- .../extensions/linearMode/linearModeTypes.ts | 1 + .../linearMode/linearOutputStore.test.ts | 34 ++++++++ .../linearMode/linearOutputStore.ts | 17 ++-- .../linearMode/useOutputHistory.test.ts | 78 +++++++++++++++++++ .../extensions/linearMode/useOutputHistory.ts | 64 ++++++++++++++- src/stores/appModeStore.ts | 31 +++++--- src/stores/executionStore.ts | 8 ++ 27 files changed, 471 insertions(+), 107 deletions(-) diff --git a/src/components/appMode/AppModeToolbar.vue b/src/components/appMode/AppModeToolbar.vue index 3d66fafc0e..0162e065a5 100644 --- a/src/components/appMode/AppModeToolbar.vue +++ b/src/components/appMode/AppModeToolbar.vue @@ -7,7 +7,6 @@ import { useErrorHandling } from '@/composables/useErrorHandling' import { useFeatureFlags } from '@/composables/useFeatureFlags' import Button from '@/components/ui/button/Button.vue' import { useAppMode } from '@/composables/useAppMode' -import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog' import { isCloud } from '@/platform/distribution/types' import { openShareDialog, @@ -44,10 +43,6 @@ function openAssets() { function showApps() { void commandStore.execute('Workspace.ToggleSidebarTab.apps') } - -function openTemplates() { - useWorkflowTemplateSelectorDialog().show('sidebar') -} diff --git a/src/components/builder/AppBuilder.vue b/src/components/builder/AppBuilder.vue index 24318aa44f..1b1dedb80c 100644 --- a/src/components/builder/AppBuilder.vue +++ b/src/components/builder/AppBuilder.vue @@ -217,7 +217,7 @@ const renderedInputs = computed<[string, MaybeRef | undefined][]>( isArrangeMode ? t('nodeHelpPage.inputs') : t('linearMode.builder.title') }} -
+
| undefined][]>( :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" > @@ -266,14 +265,10 @@ const renderedInputs = computed<[string, MaybeRef | undefined][]>( -
| undefined][]>( /> +
+ {{ t('linearMode.builder.inputPlaceholder') }} +
| undefined][]>( -
| undefined][]>( /> +
+ {{ t('linearMode.builder.outputPlaceholder') }} + + {{ t('linearMode.builder.outputRequiredPlaceholder') }} + +
diff --git a/src/components/builder/BuilderDefaultModeAppliedDialogContent.vue b/src/components/builder/BuilderDefaultModeAppliedDialogContent.vue index ee8c8f4dcd..16330d5dd1 100644 --- a/src/components/builder/BuilderDefaultModeAppliedDialogContent.vue +++ b/src/components/builder/BuilderDefaultModeAppliedDialogContent.vue @@ -38,8 +38,8 @@ - @@ -58,5 +58,6 @@ defineProps<{ defineEmits<{ viewApp: [] close: [] + exitToWorkflow: [] }>() diff --git a/src/components/builder/BuilderFooterToolbar.vue b/src/components/builder/BuilderFooterToolbar.vue index b76752947b..9ca7b33f95 100644 --- a/src/components/builder/BuilderFooterToolbar.vue +++ b/src/components/builder/BuilderFooterToolbar.vue @@ -58,6 +58,6 @@ useEventListener(window, 'keydown', (e: KeyboardEvent) => { }) function onExitBuilder() { - void appModeStore.exitBuilder() + appModeStore.exitBuilder() } diff --git a/src/components/builder/BuilderMenu.vue b/src/components/builder/BuilderMenu.vue index cc23990845..ec7b732b48 100644 --- a/src/components/builder/BuilderMenu.vue +++ b/src/components/builder/BuilderMenu.vue @@ -94,7 +94,7 @@ function onEnterAppMode(close: () => void) { } function onExitBuilder(close: () => void) { - void appModeStore.exitBuilder() + appModeStore.exitBuilder() close() } diff --git a/src/components/builder/useAppSetDefaultView.test.ts b/src/components/builder/useAppSetDefaultView.test.ts index a2e010fb7b..8bd2f720e4 100644 --- a/src/components/builder/useAppSetDefaultView.test.ts +++ b/src/components/builder/useAppSetDefaultView.test.ts @@ -22,6 +22,10 @@ const mockApp = vi.hoisted(() => ({ const mockSetMode = vi.hoisted(() => vi.fn()) +const mockAppModeStore = vi.hoisted(() => ({ + exitBuilder: vi.fn() +})) + vi.mock('@/services/dialogService', () => ({ useDialogService: () => mockDialogService })) @@ -42,6 +46,10 @@ vi.mock('@/composables/useAppMode', () => ({ useAppMode: () => ({ setMode: mockSetMode }) })) +vi.mock('@/stores/appModeStore', () => ({ + useAppModeStore: () => mockAppModeStore +})) + vi.mock('./DefaultViewDialogContent.vue', () => ({ default: { name: 'MockDefaultViewDialogContent' } })) @@ -208,6 +216,16 @@ describe('useAppSetDefaultView', () => { expect(mockSetMode).toHaveBeenCalledWith('app') }) + it('onExitToWorkflow exits builder and closes dialog', () => { + const confirmCall = applyAndGetConfirmDialog(true) + confirmCall.props.onExitToWorkflow() + + expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({ + key: 'builder-default-view-applied' + }) + expect(mockAppModeStore.exitBuilder).toHaveBeenCalledOnce() + }) + it('onClose closes confirmation dialog', () => { const confirmCall = applyAndGetConfirmDialog(true) diff --git a/src/components/builder/useAppSetDefaultView.ts b/src/components/builder/useAppSetDefaultView.ts index 5c5fb668a9..df2635cbbc 100644 --- a/src/components/builder/useAppSetDefaultView.ts +++ b/src/components/builder/useAppSetDefaultView.ts @@ -8,6 +8,7 @@ import { useDialogStore } from '@/stores/dialogStore' import BuilderDefaultModeAppliedDialogContent from './BuilderDefaultModeAppliedDialogContent.vue' import DefaultViewDialogContent from './DefaultViewDialogContent.vue' +import { useAppModeStore } from '@/stores/appModeStore' const DIALOG_KEY = 'builder-default-view' const APPLIED_DIALOG_KEY = 'builder-default-view-applied' @@ -16,6 +17,7 @@ export function useAppSetDefaultView() { const workflowStore = useWorkflowStore() const dialogService = useDialogService() const dialogStore = useDialogStore() + const appModeStore = useAppModeStore() const { setMode } = useAppMode() const settingView = computed(() => dialogStore.isDialogOpen(DIALOG_KEY)) @@ -54,6 +56,10 @@ export function useAppSetDefaultView() { closeAppliedDialog() setMode('app') }, + onExitToWorkflow: () => { + closeAppliedDialog() + appModeStore.exitBuilder() + }, onClose: closeAppliedDialog } }) diff --git a/src/components/common/NoResultsPlaceholder.vue b/src/components/common/NoResultsPlaceholder.vue index ce27362838..afd7a00f0d 100644 --- a/src/components/common/NoResultsPlaceholder.vue +++ b/src/components/common/NoResultsPlaceholder.vue @@ -17,6 +17,7 @@ :variant="buttonVariant ?? 'textonly'" @click="$emit('action')" > + {{ buttonLabel }}
@@ -37,6 +38,7 @@ const props = defineProps<{ title?: string message: string textClass?: string + buttonIcon?: string buttonLabel?: string buttonVariant?: ButtonVariants['variant'] }>() diff --git a/src/components/common/WorkflowActionsDropdown.vue b/src/components/common/WorkflowActionsDropdown.vue index 3e94c375cb..2b48f2862f 100644 --- a/src/components/common/WorkflowActionsDropdown.vue +++ b/src/components/common/WorkflowActionsDropdown.vue @@ -49,6 +49,15 @@ function toggleLinearMode() { metadata: { source } }) } + +const tooltipPt = { + root: { + style: { transform: 'translateX(calc(50% - 16px))' } + }, + arrow: { + class: '!left-[16px]' + } +} diff --git a/src/composables/useWorkflowActionsMenu.test.ts b/src/composables/useWorkflowActionsMenu.test.ts index 73a355c809..e935b14de3 100644 --- a/src/composables/useWorkflowActionsMenu.test.ts +++ b/src/composables/useWorkflowActionsMenu.test.ts @@ -40,12 +40,21 @@ const mockMenuItemStore = vi.hoisted(() => ({ hasSeenLinear: false })) -const mockCanvasStore = vi.hoisted(() => ({ - linearMode: false -})) - const mockAppModeStore = vi.hoisted(() => ({ - enterBuilder: vi.fn() + enterBuilder: vi.fn(), + pruneLinearData: vi.fn( + ( + data?: Partial<{ + inputs: [number | string, string][] + outputs: (number | string)[] + }> + ) => ({ + inputs: data?.inputs ?? [], + outputs: data?.outputs ?? [] + }) + ), + selectedInputs: [] as [number | string, string][], + selectedOutputs: [] as (number | string)[] })) const mockFeatureFlags = vi.hoisted(() => ({ @@ -73,10 +82,6 @@ vi.mock('@/stores/menuItemStore', () => ({ useMenuItemStore: vi.fn(() => mockMenuItemStore) })) -vi.mock('@/renderer/core/canvas/canvasStore', () => ({ - useCanvasStore: vi.fn(() => mockCanvasStore) -})) - vi.mock('@/stores/appModeStore', () => ({ useAppModeStore: vi.fn(() => mockAppModeStore) })) @@ -112,8 +117,9 @@ describe('useWorkflowActionsMenu', () => { mockBookmarkStore.isBookmarked.mockReturnValue(false) mockSubgraphStore.isSubgraphBlueprint.mockReturnValue(false) mockMenuItemStore.hasSeenLinear = false - mockCanvasStore.linearMode = false mockFeatureFlags.flags.linearToggleEnabled = false + mockAppModeStore.selectedInputs.length = 0 + mockAppModeStore.selectedOutputs.length = 0 mockWorkflowStore.activeWorkflow = { path: 'test.json', isPersisted: true @@ -194,7 +200,11 @@ describe('useWorkflowActionsMenu', () => { it('shows "go to workflow mode" when in linear mode', () => { mockFeatureFlags.flags.linearToggleEnabled = true - mockCanvasStore.linearMode = true + mockWorkflowStore.activeWorkflow = { + path: 'test.json', + isPersisted: true, + activeMode: 'app' + } as ComfyWorkflow const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true }) const labels = menuLabels(menuItems.value) @@ -312,6 +322,22 @@ describe('useWorkflowActionsMenu', () => { expect(mockAppModeStore.enterBuilder).toHaveBeenCalled() }) + it('shows "Edit app" when workflow has linear data', async () => { + mockFeatureFlags.flags.linearToggleEnabled = true + mockWorkflowStore.activeWorkflow = { + path: 'test.json', + isPersisted: true + } as ComfyWorkflow + mockAppModeStore.selectedInputs.push([1, 'widget']) + mockAppModeStore.selectedOutputs.push(2) + + const { menuItems } = useWorkflowActionsMenu(vi.fn(), { isRoot: true }) + const item = findItem(menuItems.value, 'breadcrumbsMenu.editBuilderMode') + + expect(item).toBeDefined() + expect(item.isNew).toBeTruthy() + }) + it('app mode toggle executes Comfy.ToggleLinear', async () => { mockFeatureFlags.flags.linearToggleEnabled = true diff --git a/src/composables/useWorkflowActionsMenu.ts b/src/composables/useWorkflowActionsMenu.ts index 9bce704606..6990bd0a1c 100644 --- a/src/composables/useWorkflowActionsMenu.ts +++ b/src/composables/useWorkflowActionsMenu.ts @@ -12,7 +12,6 @@ import { useWorkflowBookmarkStore, useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore' -import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCommandStore } from '@/stores/commandStore' import { useMenuItemStore } from '@/stores/menuItemStore' import { useSubgraphStore } from '@/stores/subgraphStore' @@ -54,9 +53,9 @@ export function useWorkflowActionsMenu( const commandStore = useCommandStore() const subgraphStore = useSubgraphStore() const menuItemStore = useMenuItemStore() - const canvasStore = useCanvasStore() const { flags } = useFeatureFlags() - const { enterBuilder } = useAppModeStore() + const appModeStore = useAppModeStore() + const { enterBuilder, pruneLinearData } = appModeStore const targetWorkflow = computed( () => workflow?.value ?? workflowStore.activeWorkflow @@ -96,12 +95,15 @@ export function useWorkflowActionsMenu( items.push(item) } - const isLinearMode = canvasStore.linearMode + const workflowMode = + workflow?.activeMode ?? workflow?.initialMode ?? 'graph' + const isLinearMode = workflowMode === 'app' const showAppModeItems = isRoot && (menuItemStore.hasSeenLinear || flags.linearToggleEnabled) const isBookmarked = bookmarkStore.isBookmarked(workflow?.path ?? '') const toggleLinear = async () => { + await ensureWorkflowActive(targetWorkflow.value) await commandStore.execute('Comfy.ToggleLinear', { metadata: { source: 'breadcrumb_menu' } }) @@ -218,11 +220,31 @@ export function useWorkflowActionsMenu( prependSeparator: true }) + const isActive = workflow === workflowStore.activeWorkflow + const rawLd = isActive + ? { + inputs: appModeStore.selectedInputs, + outputs: appModeStore.selectedOutputs + } + : workflow?.changeTracker?.activeState?.extra?.linearData + let hasLinearData: boolean + if (rawLd) { + const { inputs, outputs } = pruneLinearData(rawLd) + hasLinearData = inputs.length > 0 || outputs.length > 0 + } else { + hasLinearData = workflow?.path?.endsWith('.app.json') ?? false + } + addItem({ id: 'enter-builder-mode', - label: t('breadcrumbsMenu.enterBuilderMode'), + label: hasLinearData + ? t('breadcrumbsMenu.editBuilderMode') + : t('breadcrumbsMenu.enterBuilderMode'), icon: 'icon-[lucide--hammer]', - command: () => enterBuilder(), + command: async () => { + await ensureWorkflowActive(targetWorkflow.value) + enterBuilder() + }, visible: showAppModeItems, isNew: true }) diff --git a/src/locales/en/main.json b/src/locales/en/main.json index 2b03a4b91e..acf75e3c4d 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -1330,6 +1330,7 @@ "Rename": "Rename", "Save": "Save", "Save As": "Save As", + "Share": "Share", "Show Settings Dialog": "Show Settings Dialog", "Set Subgraph Description": "Set Subgraph Description", "Set Subgraph Search Aliases": "Set Subgraph Search Aliases", @@ -2598,7 +2599,8 @@ "duplicate": "Duplicate", "enterAppMode": "Enter app mode", "exitAppMode": "Exit app mode", - "enterBuilderMode": "App builder", + "enterBuilderMode": "Build app", + "editBuilderMode": "Edit app", "workflowActions": "Workflow actions", "clearWorkflow": "Clear Workflow", "deleteWorkflow": "Delete Workflow", @@ -3007,6 +3009,11 @@ "share": "Share", "shareTooltip": "Share workflow" }, + "shareNoOutputs": { + "title": "App has no outputs", + "message": "You're about to share an app without outputs. It can't be used until an output is connected.\n\nShare anyway?", + "shareAnyway": "Share anyway" + }, "shareWorkflow": { "shareLinkTab": "Share", "publishToHubTab": "Publish", @@ -3156,6 +3163,7 @@ "linearMode": { "linearMode": "App Mode", "beta": "App mode in beta", + "buildAnApp": "Build an app", "giveFeedback": "Give feedback", "graphMode": "Graph Mode", "dragAndDropImage": "Click to browse or drag an image", @@ -3176,14 +3184,15 @@ "controls": "Your outputs appear at the bottom, your controls are on the right. Everything else stays out of the way.", "sharing": "Share your workflow as a simple tool anyone can use. Export it from the tab menu and when others open it, they'll see App Mode. No node graph knowledge needed.", "getStarted": "Click {runButton} to get started.", - "buildApp": "Build app" + "buildApp": "Build app", + "noOutputs": "An app needs at least {count} to be usable.", + "oneOutput": "1 output" }, "appModeToolbar": { "appBuilder": "App builder", "apps": "Apps", "appsEmptyMessage": "Saved apps will show up here.", - "appsEmptyMessageAction": "Click below to build your first app.", - "enterAppMode": "Enter app mode" + "appsEmptyMessageAction": "Click below to build your first app." }, "arrange": { "noOutputs": "No outputs added yet", @@ -3208,7 +3217,10 @@ "noOutputs": "No output nodes added yet", "outputsDesc": "Connect at least one output node so users can see results after running.", "outputsExample": "Examples: “Save Image” or “Save Video”", - "unknownWidget": "Widget not visible" + "unknownWidget": "Widget not visible", + "inputPlaceholder": "Inputs will show up here", + "outputPlaceholder": "Output nodes will show up here", + "outputRequiredPlaceholder": "At least one node is required" }, "queue": { "clickToClear": "Click to clear queue", @@ -3520,6 +3532,7 @@ "defaultModeAppliedGraphBody": "This workflow will open as a node graph by default from now on.", "defaultModeAppliedGraphPrompt": "Would you like to view the app still?", "viewApp": "View app", + "exitToWorkflow": "Exit to workflow", "emptyWorkflowTitle": "This workflow has no nodes", "emptyWorkflowPrompt": "Do you want to start with a template?" }, diff --git a/src/platform/workflow/sharing/composables/useShareDialog.ts b/src/platform/workflow/sharing/composables/useShareDialog.ts index 8cc90a5401..7699b93fa9 100644 --- a/src/platform/workflow/sharing/composables/useShareDialog.ts +++ b/src/platform/workflow/sharing/composables/useShareDialog.ts @@ -1,18 +1,57 @@ import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue' import { useDialogService } from '@/services/dialogService' import { useDialogStore } from '@/stores/dialogStore' +import { useWorkflowStore } from '../../management/stores/workflowStore' +import { useAppModeStore } from '@/stores/appModeStore' +import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog' +import { t } from '@/i18n' const DIALOG_KEY = 'global-share-workflow' export function useShareDialog() { const dialogService = useDialogService() const dialogStore = useDialogStore() + const { pruneLinearData } = useAppModeStore() + const workflowStore = useWorkflowStore() function hide() { dialogStore.closeDialog({ key: DIALOG_KEY }) } - function show() { + function showNoOutputsDialogIfRequired(share: () => void) { + const wf = workflowStore.activeWorkflow + if (!wf) return share() + + const isAppDefault = wf.initialMode === 'app' + const linearData = wf.changeTracker?.activeState?.extra?.linearData + const { outputs } = pruneLinearData(linearData) + + if (isAppDefault && outputs.length === 0) { + const dialog = showConfirmDialog({ + headerProps: { + title: t('shareNoOutputs.title') + }, + props: { + promptText: t('shareNoOutputs.message'), + preserveNewlines: true + }, + footerProps: { + confirmText: t('shareNoOutputs.shareAnyway'), + confirmVariant: 'secondary', + onCancel: () => dialogStore.closeDialog(dialog), + onConfirm: () => { + dialogStore.closeDialog(dialog) + share() + } + } + }) + return + } + + share() + } + + function showShareDialog() { dialogService.showLayoutDialog({ key: DIALOG_KEY, component: ShareWorkflowDialogContent, @@ -29,6 +68,10 @@ export function useShareDialog() { }) } + function show() { + showNoOutputsDialogIfRequired(showShareDialog) + } + return { show, hide diff --git a/src/renderer/extensions/linearMode/LinearPreview.vue b/src/renderer/extensions/linearMode/LinearPreview.vue index 2d17bacc77..209bcc3365 100644 --- a/src/renderer/extensions/linearMode/LinearPreview.vue +++ b/src/renderer/extensions/linearMode/LinearPreview.vue @@ -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() const selectedOutput = ref() const canShowPreview = ref(true) const latentPreview = ref() +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) {