App mode mobile redesign (#9047)

Reworks the app mode display for mobile devices. Adds multiple bottom
tabs that can be swiped between.


![AnimateDiff_00005](https://github.com/user-attachments/assets/e1c928ff-dd52-4f4c-83a6-c351c4711e62)

To be handled in followup PRs
- Nicer error display
- Support for even smaller screens
- UX improvements for the 'Outputs' pane
  - Was postponed to minimize conflicts with non-mobile development.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9047-App-mode-mobile-redesign-30e6d73d365081388e4adea4df886522)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
AustinMroz
2026-03-03 14:18:19 -08:00
committed by GitHub
parent 68b16e3a3f
commit fe8ab1d896
12 changed files with 482 additions and 246 deletions

View File

@@ -31,7 +31,7 @@ const height = ref('')
<img
v-else
ref="imageRef"
class="w-full"
class="contain-size grow-1 object-contain"
:src
@load="
() => {

View File

@@ -5,6 +5,7 @@ import { storeToRefs } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useI18n } from 'vue-i18n'
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'
@@ -17,20 +18,16 @@ import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import DropZone from '@/renderer/extensions/linearMode/DropZone.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import WidgetInputNumberInput from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputNumber.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const executionErrorStore = useExecutionErrorStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
const settingStore = useSettingStore()
@@ -45,9 +42,11 @@ const props = defineProps<{
mobile?: boolean
}>()
defineEmits<{ navigateAssets: [] }>()
const jobFinishedQueue = ref(true)
const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
5000,
8000,
{ controls: true, immediate: false }
)
@@ -134,17 +133,6 @@ const partitionedNodes = computed(() => {
return parts
})
const batchCountWidget: SimplifiedWidget<number> = {
options: {
precision: 0,
min: 1,
max: settingStore.get('Comfy.QueueButton.BatchCountLimit')
},
value: 1,
name: t('linearMode.runCount'),
type: 'number'
} as const
//TODO: refactor out of this file.
//code length is small, but changes should propagate
async function runButtonClick(e: Event) {
@@ -179,44 +167,13 @@ defineExpose({ runButtonClick })
<template>
<div
v-if="!isBuilderMode && hasOutputs"
class="flex flex-col min-w-80 md:h-full"
class="flex flex-col min-w-80 h-full"
v-bind="$attrs"
>
<section
v-if="mobile"
data-testid="linear-run-button"
class="p-4 pb-6 border-t border-node-component-border"
>
<WidgetInputNumberInput
v-model="batchCount"
:widget="batchCountWidget"
root-class="text-base-foreground grid-cols-[auto_96px]"
class="*:[.min-w-0]:w-24"
/>
<SubscribeToRunButton v-if="!isActiveSubscription" class="w-full mt-4" />
<div v-else class="flex mt-4 gap-2">
<Button
variant="primary"
class="grow-1"
size="lg"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
<Button
v-if="!executionStore.isIdle"
variant="destructive"
size="lg"
class="w-10 p-2"
@click="commandStore.execute('Comfy.Interrupt')"
>
<i class="icon-[lucide--x]" />
</Button>
</div>
</section>
<section
v-if="!mobile"
data-testid="linear-workflow-info"
class="h-12 border-x border-border-subtle py-2 px-4 gap-2 bg-comfy-menu-bg flex items-center md:contain-size"
class="h-12 border-x border-border-subtle py-2 px-4 gap-2 bg-comfy-menu-bg flex items-center contain-size"
>
<span
class="font-bold truncate"
@@ -254,11 +211,11 @@ defineExpose({ runButtonClick })
<Button v-if="false"> {{ t('menuLabels.publish') }} </Button>
</section>
<div
class="border gap-2 md:h-full border-[var(--interface-stroke)] bg-comfy-menu-bg flex flex-col px-2"
class="border-x md:border-y gap-2 h-full border-[var(--interface-stroke)] bg-comfy-menu-bg flex flex-col px-2"
>
<section
data-testid="linear-widgets"
class="grow-1 md:overflow-y-auto md:contain-size"
class="grow-1 overflow-y-auto contain-size"
>
<template
v-for="(nodeData, index) of appModeStore.selectedInputs.length
@@ -280,7 +237,7 @@ defineExpose({ runButtonClick })
:node-data
:class="
cn(
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg',
'py-3 gap-y-3 **:[.col-span-2]:grid-cols-1 *:has-[textarea]:h-50 rounded-lg **:[.h-7]:h-10',
nodeData.hasErrors &&
'ring-2 ring-inset ring-node-stroke-error'
)
@@ -289,16 +246,89 @@ defineExpose({ runButtonClick })
</DropZone>
</template>
</section>
<Teleport
v-if="!jobToastTimeout || !jobFinishedQueue"
defer
:disabled="mobile"
:to="toastTo"
>
<div
class="bg-base-foreground md:bg-secondary-background text-base-background md:text-base-foreground rounded-lg flex h-10 md:h-8 p-1 pr-2 gap-2 items-center"
>
<template v-if="jobFinishedQueue">
<i
class="icon-[lucide--check] size-5 not-md:bg-success-background"
/>
<span class="mr-auto" v-text="t('queue.jobAddedToQueue')" />
<Button
v-if="mobile"
variant="inverted"
@click="$emit('navigateAssets')"
>
{{ t('linearMode.viewJob') }}
</Button>
</template>
<template v-else>
<i class="icon-[lucide--loader-circle] size-4 animate-spin" />
<span v-text="t('queue.jobQueueing')" />
</template>
</div>
</Teleport>
<section
v-if="!mobile"
v-if="mobile"
data-testid="linear-run-button"
class="p-4 pb-6 border-t border-node-component-border"
>
<WidgetInputNumberInput
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="w-full mt-4"
/>
<div v-else class="flex mt-4">
<Popover side="top" @open-auto-focus.prevent>
<template #button>
<Button size="lg" class="-mr-3 pr-7">
<i v-if="batchCount == 1" class="icon-[lucide--chevron-down]" />
<div v-else class="tabular-nums" v-text="`${batchCount}x`" />
</Button>
</template>
<div
class="mb-2 m-1 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
/>
<ScrubableNumberInput
v-model="batchCount"
:aria-label="t('linearMode.runCount')"
:min="1"
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-10 min-w-40"
/>
</Popover>
<Button
variant="primary"
class="grow-1"
size="lg"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</div>
</section>
<section
v-else
data-testid="linear-run-button"
class="p-4 pb-6 border-t border-node-component-border"
>
<div
class="mb-2 m-1 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
/>
<ScrubableNumberInput
v-model="batchCount"
:widget="batchCountWidget"
root-class="text-base-foreground grid-cols-[auto_96px]"
class="*:[.min-w-0]:w-24"
:aria-label="t('linearMode.runCount')"
:min="1"
:max="settingStore.get('Comfy.QueueButton.BatchCountLimit')"
class="h-7 min-w-40"
/>
<SubscribeToRunButton
v-if="!isActiveSubscription"
@@ -317,24 +347,4 @@ defineExpose({ runButtonClick })
</section>
</div>
</div>
<Teleport
v-if="(!jobToastTimeout || !jobFinishedQueue) && toastTo"
defer
:to="toastTo"
>
<div
class="bg-secondary-background text-base-foreground rounded-lg flex h-8 p-1 pr-2 gap-2 items-center"
>
<i
v-if="jobFinishedQueue"
class="icon-[lucide--check] size-5 text-muted-foreground"
/>
<i v-else class="icon-[lucide--loader-circle] size-4 animate-spin" />
<span
v-text="
jobFinishedQueue ? t('queue.jobAddedToQueue') : t('queue.jobQueueing')
"
/>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import { useFullscreen, usePointerSwipe } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useQueueStore } from '@/stores/queueStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { cn } from '@/utils/tailwindUtil'
const tabs = [
['linearMode.mobileControls', 'icon-[lucide--play]'],
['nodeHelpPage.outputs', 'icon-[comfy--image-ai-edit]'],
['sideToolbar.assets', 'icon-[lucide--images]']
]
const canvasStore = useCanvasStore()
const colorPaletteService = useColorPaletteService()
const colorPaletteStore = useColorPaletteStore()
const { isLoggedIn } = useCurrentUser()
const { t } = useI18n()
const { commandIdToMenuItem } = useMenuItemStore()
const queueStore = useQueueStore()
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const { toggle: toggleFullscreen } = useFullscreen(undefined, {
autoExit: true
})
const activeIndex = ref(2)
const sliderPaneRef = useTemplateRef('sliderPaneRef')
const sliderWidth = computed(() => sliderPaneRef.value?.offsetWidth)
const { distanceX, isSwiping } = usePointerSwipe(sliderPaneRef, {
disableTextSelect: true,
onSwipeEnd() {
if (
!sliderWidth.value ||
Math.abs(distanceX.value) / sliderWidth.value < 0.4
)
return
if (distanceX.value < 0)
activeIndex.value = Math.max(activeIndex.value - 1, 0)
else activeIndex.value = Math.min(activeIndex.value + 1, tabs.length - 1)
}
})
const translate = computed(() => {
const slideOffset =
isSwiping.value && sliderWidth.value
? distanceX.value / sliderWidth.value
: 0
const totalOffset = slideOffset + activeIndex.value
return `${totalOffset * -100}vw`
})
function onClick(index: number) {
if (Math.abs(distanceX.value) > 30) return
activeIndex.value = index
}
const workflowsEntries = computed(() => {
return workflowStore.openWorkflows.map((w) => ({
label: w.filename,
icon: w.activeState?.extra?.linearMode
? 'icon-[lucide--panels-top-left] bg-primary-background'
: undefined,
command: () => workflowService.openWorkflow(w)
}))
})
const menuEntries = computed<MenuItem[]>(() => [
{
label: t('linearMode.appModeToolbar.apps'),
icon: 'icon-[lucide--panels-top-left]'
},
{
...commandIdToMenuItem('Comfy.BrowseTemplates'),
label: t('sideToolbar.templates'),
icon: 'icon-[comfy--template]'
},
{ separator: true },
{
label: t('icon.file'),
items: [
commandIdToMenuItem('Comfy.RenameWorkflow'),
commandIdToMenuItem('Comfy.DuplicateWorkflow'),
{ separator: true },
commandIdToMenuItem('Comfy.SaveWorkflow'),
commandIdToMenuItem('Comfy.SaveWorkflowAs'),
{ separator: true },
commandIdToMenuItem('Comfy.ExportWorkflow'),
commandIdToMenuItem('Comfy.ExportWorkflowAPI')
]
},
{
label: t('g.edit'),
items: [
commandIdToMenuItem('Comfy.Undo'),
commandIdToMenuItem('Comfy.Redo'),
{ separator: true },
commandIdToMenuItem('Comfy.RefreshNodeDefinitions'),
commandIdToMenuItem('Comfy.Memory.UnloadModels'),
commandIdToMenuItem('Comfy.Memory.UnloadModelsAndExecutionCache')
]
},
{
label: t('linearMode.enterNodeGraph'),
icon: 'icon-[comfy--workflow]',
new: true,
command: () => (canvasStore.linearMode = false)
},
{ separator: true },
{
label: t('menu.theme'),
items: colorPaletteStore.palettes.map((palette) => ({
label: palette.name,
icon:
colorPaletteStore.activePaletteId === palette.id
? 'icon-[lucide--check]'
: '',
command: () => colorPaletteService.loadColorPalette(palette.id)
}))
},
{ separator: true },
{
...commandIdToMenuItem('Comfy.ShowSettingsDialog'),
label: t('menu.settings')
},
{ ...commandIdToMenuItem('Comfy.ToggleHelpCenter'), label: t('menu.help') },
{
label: t('menu.fullscreen'),
icon: 'icon-[lucide--fullscreen]',
command: toggleFullscreen
}
])
</script>
<template>
<section class="absolute w-full h-full flex flex-col bg-secondary-background">
<header
class="w-full h-16 px-4 py-3 flex border-border-subtle border-b items-center gap-3 bg-base-background"
>
<DropdownMenu :entries="menuEntries" />
<Popover
:entries="workflowsEntries"
class="w-(--reka-popover-content-available-width)"
:collision-padding="20"
>
<template #button>
<!--TODO: Use button here? Probably too much work to destyle-->
<div
class="bg-secondary-background h-10 rounded-sm grow-1 flex items-center p-2 gap-2"
>
<i
class="shrink-0 icon-[lucide--panels-top-left] bg-primary-background"
/>
<span
class="truncate contain-size h-full w-full"
v-text="workflowStore.activeWorkflow?.filename"
/>
<i
class="shrink-0 icon-[lucide--chevron-down] bg-muted-foreground"
/>
</div>
</template>
</Popover>
<CurrentUserButton v-if="isLoggedIn" :show-arrow="false" />
</header>
<div class="size-full contain-content rounded-b-4xl">
<div
:class="
cn('size-full relative', !isSwiping && 'transition-[translate]')
"
:style="{ translate }"
>
<div class="overflow-y-auto contain-size h-full w-screen absolute">
<LinearControls mobile @navigate-assets="activeIndex = 2" />
</div>
<div
class="w-screen absolute h-full bg-base-background left-[100vw] top-0 flex flex-col"
>
<LinearPreview mobile />
</div>
<AssetsSidebarTab
class="h-full w-screen absolute bg-base-background left-[200vw] top-0"
/>
</div>
</div>
<div
ref="sliderPaneRef"
class="h-22 p-4 w-full flex gap-4 items-center justify-around bg-secondary-background"
>
<Button
v-for="([label, icon], index) in tabs"
:key="label"
:variant="index === activeIndex ? 'secondary' : 'muted-textonly'"
class="flex-col h-14 grow-1"
@click="onClick(index)"
>
<div class="relative size-4">
<i :class="cn('size-4', icon)" />
<div
v-if="
index === 1 &&
(queueStore.runningTasks.length > 0 ||
queueStore.pendingTasks.length > 0)
"
class="absolute bg-primary-background size-2 -top-1 -right-1 rounded-full animate-pulse"
/>
</div>
{{ t(label) }}
</Button>
</div>
</section>
</template>

View File

@@ -1,60 +0,0 @@
<script setup lang="ts">
import {
CollapsibleRoot,
CollapsibleTrigger,
CollapsibleContent
} from 'reka-ui'
import { useI18n } from 'vue-i18n'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import Button from '@/components/ui/button/Button.vue'
import Popover from '@/components/ui/Popover.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useCommandStore } from '@/stores/commandStore'
const { t } = useI18n()
</script>
<template>
<CollapsibleRoot class="flex flex-col">
<CollapsibleTrigger as-child>
<Button variant="secondary" class="size-10 self-end m-4 mb-2">
<i class="icon-[lucide--menu] size-8" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent class="flex gap-2 flex-col">
<div class="w-full border-b-2 border-border-subtle" />
<Popover>
<template #button>
<Button variant="secondary" size="lg" class="w-full">
<i class="icon-[comfy--workflow]" />
{{ t('Workflows') }}
</Button>
</template>
<WorkflowsSidebarTab class="h-300 w-[80vw]" />
</Popover>
<Button
variant="secondary"
size="lg"
class="w-full"
@click="useWorkflowTemplateSelectorDialog().show('menu')"
>
<i class="icon-[comfy--template]" />
{{ t('sideToolbar.templates') }}
</Button>
<Button
variant="secondary"
size="lg"
class="w-full"
@click="
useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source: 'button' }
})
"
>
<i class="icon-[lucide--log-out]" />
{{ t('linearMode.graphMode') }}
</Button>
<div class="w-full border-b-2 border-border-subtle" />
</CollapsibleContent>
</CollapsibleRoot>
</template>