Files
ComfyUI_frontend/src/components/topbar/WorkflowTabs.vue
pythongosssss 1687ca93b3 feat: Integrated tab UI updates (#8516)
## Summary
Next iteration of the integrated tab/top menu

## Changes
- **What**:  
- make integrated default, rename old to legacy
- move feedback to integrated
- fix user icon shapes
- remove comfy cloud text in top bar, move to canvas stats
- add chevron to C logo menu
- move help back to sidebar
   - remove now unused help top positioning code

## Screenshots (if applicable)
<img width="428" height="148" alt="image"
src="https://github.com/user-attachments/assets/725025b7-4982-4f61-be11-8aabb0a1faff"
/>
<img width="264" height="187" alt="image"
src="https://github.com/user-attachments/assets/91fa5e92-df08-4467-9bc5-50a614d9b8aa"
/>
<img width="1169" height="220" alt="image"
src="https://github.com/user-attachments/assets/68c81bea-0cff-48df-8303-a6231a1d2fc4"
/>
<img width="242" height="207" alt="image"
src="https://github.com/user-attachments/assets/5a10f40e-83ae-44c3-9434-3dbe87ba30e2"
/>
<img width="302" height="222" alt="image"
src="https://github.com/user-attachments/assets/27fcc638-5fff-4302-9a1f-066227aafd86"
/>

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-03-07 11:20:01 -08:00

407 lines
11 KiB
Vue

<template>
<div
ref="containerRef"
class="workflow-tabs-container flex h-full max-w-full flex-auto flex-row overflow-hidden"
:class="{ 'workflow-tabs-container-desktop': isDesktop }"
>
<Button
v-if="showOverflowArrows"
variant="muted-textonly"
size="icon"
class="overflow-arrow overflow-arrow-left aspect-square h-full w-auto"
:aria-label="$t('g.scrollLeft')"
:disabled="!leftArrowEnabled"
@mousedown="whileMouseDown($event, () => scroll(-1))"
>
<i class="icon-[lucide--chevron-left] size-full" />
</Button>
<ScrollPanel
class="no-drag overflow-hidden"
:pt:content="{
class: 'p-0 w-full flex',
onwheel: handleWheel
}"
pt:bar-x="h-1"
>
<SelectButton
class="workflow-tabs bg-transparent"
:class="props.class"
:model-value="selectedWorkflow"
:options="options"
option-label="label"
data-key="value"
@update:model-value="onWorkflowChange"
>
<template #option="{ option, index }">
<WorkflowTab
:workflow-option="option"
:is-first="index === 0"
:is-last="index === options.length - 1"
@click.middle="onCloseWorkflow(option)"
@close-to-left="closeWorkflows(options.slice(0, index))"
@close-to-right="closeWorkflows(options.slice(index + 1))"
@close-others="
closeWorkflows([
...options.slice(index + 1),
...options.slice(0, index)
])
"
/>
</template>
</SelectButton>
</ScrollPanel>
<Button
v-if="showOverflowArrows"
variant="muted-textonly"
size="icon"
class="overflow-arrow overflow-arrow-right aspect-square h-full w-auto"
:aria-label="$t('g.scrollRight')"
:disabled="!rightArrowEnabled"
@mousedown="whileMouseDown($event, () => scroll(1))"
>
<i class="icon-[lucide--chevron-right] size-full" />
</Button>
<WorkflowOverflowMenu
v-if="showOverflowArrows"
:workflows="workflowStore.openWorkflows"
:active-workflow="workflowStore.activeWorkflow"
/>
<Button
v-tooltip="{
value: $t('sideToolbar.newBlankWorkflow'),
showDelay: 300
}"
class="new-blank-workflow-button no-drag aspect-square h-full w-auto shrink-0 rounded-none"
variant="muted-textonly"
size="icon"
:aria-label="$t('sideToolbar.newBlankWorkflow')"
@click="() => commandStore.execute('Comfy.NewBlankWorkflow')"
>
<i class="pi pi-plus" />
</Button>
<div
v-if="isIntegratedTabBar"
class="ml-auto flex shrink-0 items-center gap-2 px-2"
>
<Button
v-if="isCloud || isNightly"
v-tooltip="{ value: $t('actionbar.feedbackTooltip'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
class="shrink-0 text-base-foreground"
:aria-label="$t('actionbar.feedback')"
@click="openFeedback"
>
<i class="icon-[lucide--message-square-text]" />
</Button>
<CurrentUserButton v-if="showCurrentUser" compact class="shrink-0 p-1" />
<LoginButton v-else-if="isDesktop" class="p-1" />
</div>
<div v-if="isDesktop" class="window-actions-spacer app-drag shrink-0" />
</div>
</template>
<script setup lang="ts">
import { useScroll } from '@vueuse/core'
import ScrollPanel from 'primevue/scrollpanel'
import SelectButton from 'primevue/selectbutton'
import { computed, nextTick, onUpdated, ref, watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
import LoginButton from '@/components/topbar/LoginButton.vue'
import WorkflowTab from '@/components/topbar/WorkflowTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useOverflowObserver } from '@/composables/element/useOverflowObserver'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildFeedbackUrl } from '@/platform/support/config'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { whileMouseDown } from '@/utils/mouseDownUtil'
import WorkflowOverflowMenu from './WorkflowOverflowMenu.vue'
interface WorkflowOption {
value: string
workflow: ComfyWorkflow
}
const props = defineProps<{
class?: string
}>()
const settingStore = useSettingStore()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const commandStore = useCommandStore()
const { isLoggedIn } = useCurrentUser()
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)
const showCurrentUser = computed(() => isCloud || isLoggedIn.value)
const feedbackUrl = buildFeedbackUrl()
function openFeedback() {
window.open(feedbackUrl, '_blank', 'noopener,noreferrer')
}
const containerRef = ref<HTMLElement | null>(null)
const showOverflowArrows = ref(false)
const leftArrowEnabled = ref(false)
const rightArrowEnabled = ref(false)
const workflowToOption = (workflow: ComfyWorkflow): WorkflowOption => ({
value: workflow.path,
workflow
})
const options = computed<WorkflowOption[]>(() =>
workflowStore.openWorkflows.map(workflowToOption)
)
const selectedWorkflow = computed<WorkflowOption | null>(() =>
workflowStore.activeWorkflow
? workflowToOption(workflowStore.activeWorkflow as ComfyWorkflow)
: null
)
const onWorkflowChange = async (option: WorkflowOption) => {
// Prevent unselecting the current workflow
if (!option) {
return
}
// Prevent reloading the current workflow
if (selectedWorkflow.value?.value === option.value) {
return
}
await workflowService.openWorkflow(option.workflow)
}
const closeWorkflows = async (options: WorkflowOption[]) => {
for (const opt of options) {
if (
!(await workflowService.closeWorkflow(opt.workflow, {
warnIfUnsaved: !workspaceStore.shiftDown
}))
) {
// User clicked cancel
break
}
}
}
const onCloseWorkflow = async (option: WorkflowOption) => {
await closeWorkflows([option])
}
// Horizontal scroll on wheel
const handleWheel = (event: WheelEvent) => {
const scrollElement = event.currentTarget as HTMLElement
const scrollAmount = event.deltaX || event.deltaY
scrollElement.scroll({
left: scrollElement.scrollLeft + scrollAmount
})
}
const scrollContent = computed(
() =>
(containerRef.value?.querySelector(
'.p-scrollpanel-content'
) as HTMLElement | null) ?? null
)
const scroll = (direction: number) => {
const el = scrollContent.value
if (!el) return
el.scrollBy({ left: direction * 20 })
}
const ensureActiveTabVisible = async (
options: { waitForDom?: boolean } = {}
) => {
if (!selectedWorkflow.value) return
if (options.waitForDom !== false) {
await nextTick()
}
const containerElement = containerRef.value
if (!containerElement) return
const activeTabElement = containerElement.querySelector(
'.p-togglebutton-checked'
)
if (!activeTabElement) return
activeTabElement.scrollIntoView({ block: 'nearest', inline: 'nearest' })
}
// Scroll to active offscreen tab when opened
watch(
() => workflowStore.activeWorkflow,
() => {
void ensureActiveTabVisible()
},
{ immediate: true }
)
let overflowObserver: ReturnType<typeof useOverflowObserver> | null = null
let stopArrivedWatch: WatchStopHandle | null = null
let stopOverflowWatch: WatchStopHandle | null = null
watch(
scrollContent,
(el, _prev, onCleanup) => {
stopArrivedWatch?.()
stopOverflowWatch?.()
overflowObserver?.dispose()
if (!el) return
const scrollState = useScroll(el)
stopArrivedWatch = watch(
[
() => scrollState.arrivedState.left,
() => scrollState.arrivedState.right
],
([atLeft, atRight]) => {
leftArrowEnabled.value = !atLeft
rightArrowEnabled.value = !atRight
},
{ immediate: true }
)
overflowObserver = useOverflowObserver(el)
stopOverflowWatch = watch(
overflowObserver.isOverflowing,
(isOverflow) => {
showOverflowArrows.value = isOverflow
if (!isOverflow) return
void nextTick(() => {
// Force a new check after arrows are updated
scrollState.measure()
void ensureActiveTabVisible({ waitForDom: false })
})
},
{ immediate: true }
)
onCleanup(() => {
stopArrivedWatch?.()
stopOverflowWatch?.()
overflowObserver?.dispose()
})
},
{ immediate: true }
)
onUpdated(() => {
if (!overflowObserver?.disposed.value) {
overflowObserver?.checkOverflow()
}
})
</script>
<style scoped>
.workflow-tabs-container {
background-color: var(--comfy-menu-bg);
}
:deep(.p-togglebutton) {
position: relative;
flex-shrink: 1;
border: 0;
border-right-style: solid;
border-right-width: 1px;
border-radius: 0;
background-color: transparent;
padding: 0;
border-right-color: var(--border-color);
min-width: 90px;
}
.overflow-arrow {
border-radius: 0;
padding-inline: calc(var(--spacing) * 2);
}
.overflow-arrow[disabled] {
opacity: 0.25;
}
:deep(.p-togglebutton > .p-togglebutton-content) {
max-width: 100%;
}
:deep(.workflow-tab) {
max-width: 100%;
}
:deep(.p-togglebutton::before) {
display: none;
}
:deep(.p-togglebutton:first-child) {
border-left-style: solid;
border-left-width: 1px;
border-left-color: var(--border-color);
}
:deep(.p-togglebutton:not(:first-child)) {
border-left-width: 0;
}
:deep(.p-togglebutton.p-togglebutton-checked) {
height: 100%;
border-bottom-style: solid;
border-bottom-width: 1px;
border-bottom-color: var(--p-button-text-primary-color);
}
:deep(.p-togglebutton:not(.p-togglebutton-checked)) {
opacity: 0.75;
}
:deep(.p-togglebutton-checked) .close-button,
:deep(.p-togglebutton:hover) .close-button {
visibility: visible;
}
:deep(.p-scrollpanel-content) {
height: 100%;
}
:deep(.workflow-tabs) {
display: flex;
}
/* Scrollbar half opacity to avoid blocking the active tab bottom border */
:deep(.p-scrollpanel:hover .p-scrollpanel-bar),
:deep(.p-scrollpanel:active .p-scrollpanel-bar) {
opacity: 0.5;
}
:deep(.p-selectbutton) {
height: 100%;
border-radius: 0;
}
.workflow-tabs-container-desktop {
max-width: env(titlebar-area-width, 100vw);
}
.window-actions-spacer {
flex: auto;
/* If we are using custom titlebar, then we need to add a gap for the user to drag the window */
--window-actions-spacer-width: min(75px, env(titlebar-area-width, 0) * 9999);
min-width: var(--window-actions-spacer-width);
}
</style>