Compare commits

...

27 Commits

Author SHA1 Message Date
pythongosssss
57de75748e feat: icon-only output action bar, right-aligned and always visible
- replace text buttons with icon buttons + reka tooltips (new ui/tooltip component)
- order: more, rerun, reuse, share, download; disabled without selection
- add always-active share button opening the workflow share dialog
- keep bar visible during generation
- fix LinearPreview test for GeneratingScreen replacing LatentPreview
2026-07-02 13:05:54 -07:00
pythongosssss
ec269512e7 fix test 2026-07-02 12:17:32 -07:00
pythongosssss
417f2d7b5c refactor: simplify generating-card image reveal, bound extra cards
- Replace the parent-side decode gate (decoded-src map, ready/decoding sets, liveness pruning) with a per-card image fade
- GeneratingCard derives its own src and fades it in on load; GeneratingScreen just filters cards that have content to show
- Latent-preview swaps stay flash-free via the browser keeping the prior frame until the newer src decodes — no manual bookkeeping
- Cap generatingExtraCards on insert instead of slicing at read time, so a long run no longer retains every non-selected output in memory
- Add an interactive GeneratingScreen story (add / remove / reset) to exercise the fan entrance, reflow, eviction and exit animations
2026-07-02 11:57:48 -07:00
pythongosssss
c9d02d1661 feat: add animated outputs
- add generating screen fan of images with pop in animations
- track arrival order to display cards ordered in fan
2026-07-02 11:57:48 -07:00
pythongosssss
6c2ab519ac fix: derive view-mode toggle behavior from real mode, not display lag
- Real mode drives segment behavior/aria; lagged mirror only drives the morph
- Popup semantics on the active button, menu anchored via reference prop;
  drops the stopPropagation guards
- Move viewMode/displayViewMode from canvasStore to appModeStore
- SideToolbar prop-driven (hideWorkspaceToggles); parameterized toggle aria-label
- Tests: drop utility-class asserts and Button stubs, cover missed branches
2026-07-02 10:56:29 -07:00
pythongosssss
6455a49f58 test(appMode): drop ErrorOverlay assertion from LinearView
ErrorOverlay was removed from LinearView in #12557 (replaced by the app
mode run validation warning); update the merged test to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 07:14:04 -07:00
pythongosssss
b846cf4171 Merge remote-tracking branch 'origin/main' into pysssss/app-mode-ui-updates
# Conflicts:
#	src/views/LinearView.vue
2026-07-01 03:22:28 -07:00
github-actions
e970f5457b [automated] Update test expectations 2026-06-30 12:29:04 +00:00
pythongosssss
06d5443de1 Merge remote-tracking branch 'origin/main' into pysssss/app-mode-ui-updates
# Conflicts:
#	browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-linux.png
2026-06-30 05:21:59 -07:00
pythongosssss
86219d117d test(appMode): assert builder exit lands in graph mode 2026-06-30 05:16:42 -07:00
github-actions
8ee6fc6f5f [automated] Update test expectations 2026-06-29 13:26:57 +00:00
pythongosssss
d9fd2e8c2f Merge remote-tracking branch 'origin/main' into pysssss/app-mode-ui-updates
# Conflicts:
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-default-50-chromium-linux.png
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-25-chromium-linux.png
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-75-chromium-linux.png
2026-06-29 06:06:23 -07:00
github-actions
414469ed3c [automated] Update test expectations 2026-06-29 13:00:26 +00:00
pythongosssss
8e0622e423 test: assert slot-prop contract in sidebar tab tests
- SideToolbar: assert logout icon absent for single-user before multi-user
- AppsSidebarTab: drive stubs by real hasResults and button-label props
2026-06-29 05:22:39 -07:00
pythongosssss
be251d540a test: cover app-mode UI changed lines for patch coverage
Add unit tests so the branch's changed lines in the app-mode UI are covered
by the unit flag (Codecov patch coverage):

- WorkflowActionsDropdown: segment labels, mode toggle, open telemetry
- SideToolbar: visibleTabIds filter, forceConnected, linearMode toggles
- AppsSidebarTab: createApp from header action and empty state
- AppModeToolbar: build-an-app button enable/disable/enter builder
- SubgraphBreadcrumb: actions dropdown gated by linearMode
- LinearPreview: output-history render branches across mobile/builder modes
- LinearView: layout/panel branches and splitter resize handlers

All changed instrumented lines in these files are now covered. The single
changed line in GraphCanvas.vue (SideToolbar v-if) remains e2e-covered.
2026-06-26 12:46:00 -07:00
pythongosssss
6bb1dc972f test: cover help-center feedback fallback and view-mode toggle labels
- SidebarHelpCenterIcon: assert the localized fallback renders on typeform
  load error / invalid id, and the embed mounts otherwise
- WorkflowActionsDropdown: assert the active segment keeps its visible label
  in the accessible name and the inactive segment toggles view mode
2026-06-26 11:02:29 -07:00
pythongosssss
9065b845fc fix: address app mode review feedback
- canvasStore: cancel pending RAF chain on rapid linearMode toggles so a
  stale frame can't flash the wrong view mode
- canvasStore.test: advance frames one at a time to actually cover the
  one-frame lag, add rapid-toggle regression test
- WorkflowActionsDropdown: keep the active segment's visible label in its
  accessible name (label-in-name) while preserving the "Workflow actions"
  match
- SidebarHelpCenterIcon: render a localized fallback on typeform load
  error / invalid id instead of an empty popover
- appMode.spec: assert sidebar tabs via menu fixtures instead of class
  selectors; export SidebarTab base and add an appsTab fixture
- appMode.spec: assert app-mode-only center panel after exiting the builder
2026-06-26 10:32:53 -07:00
pythongosssss
61ebcb514d use typeform embed 2026-06-25 13:11:18 -07:00
pythongosssss
b5fd5fd54c remove composable, move to store 2026-06-25 12:48:32 -07:00
pythongosssss
70c2e5e70e remove composable, move to store 2026-06-25 12:43:08 -07:00
pythongosssss
8bd12134b2 fix switching, refactor teleport to two instances 2026-06-25 04:29:41 -07:00
pythongosssss
160d7c7a63 - remove unused key
- fix keboard toggle
- simplify store to composable
- additional tests
2026-06-25 04:06:11 -07:00
pythongosssss
51efcf0424 fix sidenav tab bg color 2026-06-25 03:37:05 -07:00
pythongosssss
0975a7ffbc update app mode bg to have contrast with buttons 2026-06-25 03:25:34 -07:00
pythongosssss
8bebdb3021 refactor toggle animation to use delay on mount instead of flakey teleport 2026-06-25 03:14:16 -07:00
pythongosssss
b8207f2647 fix teleport to ensure re-mount host exists 2026-06-24 13:56:32 -07:00
pythongosssss
787815eb09 feat: update app mode UI and mode toggle
- animate and restyle mode toggle, teleport between modes
- use graph sidebar in app mode, hide elements and force connected
- replace help button in app mode with feedback
- add create buttons to apps tab
2026-06-24 13:32:00 -07:00
73 changed files with 2842 additions and 514 deletions

View File

@@ -28,6 +28,7 @@ import {
ModelLibrarySidebarTab,
NodeLibrarySidebarTab,
NodeLibrarySidebarTabV2,
SidebarTab,
WorkflowsSidebarTab
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
@@ -70,6 +71,7 @@ class ComfyPropertiesPanel {
}
class ComfyMenu {
private _appsTab: SidebarTab | null = null
private _assetsTab: AssetsSidebarTab | null = null
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
@@ -104,6 +106,11 @@ class ComfyMenu {
return this._nodeLibraryTabV2
}
get appsTab() {
this._appsTab ??= new SidebarTab(this.page, 'apps')
return this._appsTab
}
get assetsTab() {
this._assetsTab ??= new AssetsSidebarTab(this.page)
return this._assetsTab

View File

@@ -4,7 +4,7 @@ import { expect } from '@playwright/test'
import type { WorkspaceStore } from '@e2e/types/globals'
import { TestIds } from '@e2e/fixtures/selectors'
class SidebarTab {
export class SidebarTab {
public readonly tabButton: Locator
public readonly selectedTabButton: Locator

View File

@@ -238,6 +238,9 @@ export const TestIds = {
renameInput: 'subgraph-breadcrumb-rename-input',
menu: (key: string) => `subgraph-breadcrumb-menu-${key}`
},
workflowActions: {
viewModeToggle: 'view-mode-toggle'
},
templates: {
content: 'template-workflows-content',
workflowCard: (id: string) => `template-workflow-${id}`

View File

@@ -137,6 +137,125 @@ test.describe('App mode usage', () => {
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
})
test('Shares the graph side toolbar, filtered to assets + apps', async ({
comfyPage
}) => {
const { sideToolbar, nodeLibraryTab, assetsTab, appsTab } = comfyPage.menu
await test.step('Graph mode shows the full toolbar', async () => {
await expect(sideToolbar).toBeVisible()
await expect(nodeLibraryTab.tabButton).toBeVisible()
})
await test.step('App mode reuses it with only assets + apps', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(sideToolbar).toBeVisible()
await expect(assetsTab.tabButton).toBeVisible()
await expect(appsTab.tabButton).toBeVisible()
await expect(nodeLibraryTab.tabButton).toBeHidden()
})
})
test('Workflow actions menu keeps the same position across graph/app mode', async ({
comfyPage
}) => {
// Toggling graph<->app mode happens from this control, so it must not move
// out from under the cursor as the mode flips.
const graphActions = comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.getByRole('button', { name: 'Workflow actions' })
await expect(graphActions).toBeVisible()
const graphBox = await graphActions.boundingBox()
expect(graphBox).not.toBeNull()
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
const appActions = comfyPage.page
.getByTestId(TestIds.linear.centerPanel)
.getByRole('button', { name: 'Workflow actions' })
await expect(appActions).toBeVisible()
// The toggle segments reorder (morph) as the mode flips, so poll until the
// active control settles at the same x it occupied in graph mode.
await expect
.poll(async () => {
const box = await appActions.boundingBox()
return box ? Math.abs(box.x - graphBox!.x) : Infinity
})
.toBeLessThanOrEqual(1)
})
test('Toggle segment flips mode without opening the menu', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(
TestIds.workflowActions.viewModeToggle
)
await expect(toggle).toBeVisible()
await comfyPage.page.getByRole('button', { name: 'Enter app mode' }).click()
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// The inactive segment switches mode; it must not also open the actions menu.
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
await expect(toggle).toBeVisible()
})
test('Toggle segment flips mode via keyboard without opening the menu', async ({
comfyPage
}) => {
const appSegment = comfyPage.page.getByRole('button', {
name: 'Enter app mode'
})
await appSegment.focus()
await appSegment.press('Enter')
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// Keyboard activation of the inactive segment must switch mode without the
// keydown bubbling to the trigger and opening the actions menu.
await expect(comfyPage.page.getByRole('menu')).toBeHidden()
})
test('Mode toggle re-appears after exiting the builder to graph mode', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(
TestIds.workflowActions.viewModeToggle
)
await comfyPage.appMode.enableLinearMode()
await expect(toggle).toBeVisible()
await comfyPage.appMode.enterBuilder()
await expect(toggle).toBeHidden()
await expect(comfyPage.appMode.centerPanel).toBeHidden()
await comfyPage.appMode.footer.exitButton.click()
// Exiting the builder lands in graph mode: the app-mode-only center panel
// stays hidden while the toggle's teleport host re-mounts and the toggle
// re-appears.
await expect(toggle).toBeVisible()
await expect(comfyPage.appMode.centerPanel).toBeHidden()
})
test('Mode toggle survives a sidebar tab remounting the app panel', async ({
comfyPage
}) => {
const toggle = comfyPage.page.getByTestId(
TestIds.workflowActions.viewModeToggle
)
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(toggle).toBeVisible()
// Opening a sidebar tab remounts the app panel; the toggle re-renders with it.
await comfyPage.menu.assetsTab.tabButton.click()
await expect(toggle).toBeVisible()
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -1,5 +1,89 @@
@import '@comfyorg/design-system/css/style.css';
/* Generating screen ambient glow — a slowly rotating, blurred conic gradient.
--gen-angle must be a registered <angle> so the conic gradient interpolates
instead of jumping between keyframes. */
@property --gen-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
@keyframes gen-angle-spin {
to {
--gen-angle: 360deg;
}
}
.gen-glow {
position: absolute;
inset: -28%;
border-radius: 50%;
background: conic-gradient(
from var(--gen-angle),
#3b82f63b,
#8b5cf633,
#d946ef2b,
#ec489933,
#f9731629,
#14b8a62e,
#3b82f63b
);
filter: blur(60px);
opacity: 0.34;
animation: gen-angle-spin 12s linear infinite;
mask-image: radial-gradient(circle, #000 0%, #000 22%, rgb(0 0 0 / 0) 70%);
}
.gen-glow::after {
content: '';
position: absolute;
inset: 8%;
border-radius: 50%;
background: conic-gradient(
from calc(var(--gen-angle) + 120deg),
#3b82f629,
#8b5cf621,
#d946ef1c,
#ec489924,
#f973161a,
#14b8a621,
#3b82f629
);
filter: blur(34px);
opacity: 0.39;
mask-image: radial-gradient(
circle,
#000 0%,
rgb(0 0 0 / 0.62) 36%,
rgb(0 0 0 / 0.22) 50%,
rgb(0 0 0 / 0) 64%
);
}
@media (prefers-reduced-motion: reduce) {
.gen-glow {
animation: none;
}
.genfan-enter-active,
.genfan-leave-active,
.gen-card {
transition: none;
}
}
/* Generating fan cards fade in/out so adding and evicting cards stays smooth. */
.genfan-enter-active,
.genfan-leave-active {
transition: opacity 0.42s cubic-bezier(0.16, 1, 0.3, 1);
}
.genfan-enter-from,
.genfan-leave-to {
opacity: 0;
}
/* Use 0.001ms instead of 0s so transitionend/animationend events still fire
and JS listeners aren't broken. */
.disable-animations *,

View File

@@ -0,0 +1,87 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import AppModeToolbar from './AppModeToolbar.vue'
const appModeState = vi.hoisted(() => ({
enableAppBuilder: true,
hasNodes: true
}))
const enterBuilder = vi.hoisted(() => vi.fn())
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ enableAppBuilder: appModeState.enableAppBuilder })
}))
vi.mock('@/stores/appModeStore', async () => {
const { computed, reactive } = await import('vue')
return {
useAppModeStore: () =>
reactive({
enterBuilder,
hasNodes: computed(() => appModeState.hasNodes)
})
}
})
const BUILD_AN_APP = 'Build an app'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: { appModeToolbar: { buildAnApp: BUILD_AN_APP } }
}
}
})
function renderToolbar() {
const user = userEvent.setup()
const result = render(AppModeToolbar, {
global: {
plugins: [i18n],
stubs: {
WorkflowActionsDropdown: true
}
}
})
return { ...result, user }
}
describe('AppModeToolbar', () => {
beforeEach(() => {
vi.clearAllMocks()
appModeState.enableAppBuilder = true
appModeState.hasNodes = true
})
it('shows an enabled build button and enters the builder on click', async () => {
const { user } = renderToolbar()
const button = screen.getByRole('button', { name: BUILD_AN_APP })
expect(button).toBeEnabled()
await user.click(button)
expect(enterBuilder).toHaveBeenCalled()
})
it('disables the build button when there are no nodes', () => {
appModeState.hasNodes = false
renderToolbar()
expect(screen.getByRole('button', { name: BUILD_AN_APP })).toBeDisabled()
})
it('hides the build button when app building is disabled', () => {
appModeState.enableAppBuilder = false
renderToolbar()
expect(
screen.queryByRole('button', { name: BUILD_AN_APP })
).not.toBeInTheDocument()
})
})

View File

@@ -1,119 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import WorkflowActionsDropdown from '@/components/common/WorkflowActionsDropdown.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { isCloud } from '@/platform/distribution/types'
import {
openShareDialog,
prefetchShareDialog
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { useAppModeStore } from '@/stores/appModeStore'
import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
import { storeToRefs } from 'pinia'
const { t } = useI18n()
const commandStore = useCommandStore()
const workspaceStore = useWorkspaceStore()
const { enableAppBuilder } = useAppMode()
const appModeStore = useAppModeStore()
const { enterBuilder } = appModeStore
const { toastErrorHandler } = useErrorHandling()
const { flags } = useFeatureFlags()
const { hasNodes } = storeToRefs(appModeStore)
const tooltipOptions = { showDelay: 300, hideDelay: 300 }
const isAssetsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'assets'
)
const isAppsActive = computed(
() => workspaceStore.sidebarTab.activeSidebarTab?.id === 'apps'
)
function openAssets() {
void commandStore.execute('Workspace.ToggleSidebarTab.assets')
}
function showApps() {
void commandStore.execute('Workspace.ToggleSidebarTab.apps')
}
</script>
<template>
<div class="pointer-events-auto flex flex-row items-start gap-2">
<div class="pointer-events-auto flex flex-col gap-2">
<Button
v-if="enableAppBuilder"
v-tooltip.right="{
value: t('linearMode.appModeToolbar.appBuilder'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:disabled="!hasNodes"
:aria-label="t('linearMode.appModeToolbar.appBuilder')"
class="size-10 rounded-lg"
@click="enterBuilder"
>
<i class="icon-[lucide--hammer] size-4" />
</Button>
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.right="{
value: t('actionbar.shareTooltip'),
...tooltipOptions
}"
variant="secondary"
size="unset"
:aria-label="t('actionbar.shareTooltip')"
class="size-10 rounded-lg"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[lucide--send] size-4" />
</Button>
<div
class="flex w-10 flex-col overflow-hidden rounded-lg bg-secondary-background"
>
<Button
v-tooltip.right="{
value: t('sideToolbar.mediaAssets.title'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('sideToolbar.mediaAssets.title')"
:class="
cn('size-10', isAssetsActive && 'bg-secondary-background-hover')
"
@click="openAssets"
>
<i class="icon-[comfy--image-ai-edit] size-4" />
</Button>
<Button
v-tooltip.right="{
value: t('linearMode.appModeToolbar.apps'),
...tooltipOptions
}"
variant="textonly"
size="unset"
:aria-label="t('linearMode.appModeToolbar.apps')"
:class="
cn('size-10', isAppsActive && 'bg-secondary-background-hover')
"
@click="showApps"
>
<i class="icon-[lucide--panels-top-left] size-4" />
</Button>
</div>
</div>
<WorkflowActionsDropdown source="app_mode_toolbar" />
<Button
v-if="enableAppBuilder"
variant="base"
size="unset"
:disabled="!hasNodes"
:aria-label="t('linearMode.appModeToolbar.buildAnApp')"
class="h-10 gap-1.5 rounded-lg px-3 font-normal"
@click="enterBuilder"
>
<i class="icon-[lucide--hammer] size-4" />
<span>{{ t('linearMode.appModeToolbar.buildAnApp') }}</span>
</Button>
</div>
</template>

View File

@@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SubgraphBreadcrumb from './SubgraphBreadcrumb.vue'
const canvasState = vi.hoisted(() => ({ linearMode: false }))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({ activeWorkflow: { filename: 'workflow.json' } })
}))
vi.mock('@/stores/subgraphNavigationStore', () => ({
useSubgraphNavigationStore: () => ({ navigationStack: [] })
}))
vi.mock('@/stores/subgraphStore', () => ({
useSubgraphStore: () => ({ isSubgraphBlueprint: () => false })
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ linearMode: canvasState.linearMode })
}))
vi.mock('@/composables/element/useOverflowObserver', () => ({
useOverflowObserver: () => ({
dispose: vi.fn(),
checkOverflow: vi.fn(),
disposed: { value: false }
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: { g: { graphNavigation: 'Graph navigation' } }
}
})
function renderBreadcrumb() {
return render(SubgraphBreadcrumb, {
global: {
plugins: [i18n],
directives: { tooltip: {} },
stubs: {
WorkflowActionsDropdown: { template: '<div data-testid="wad" />' },
Breadcrumb: true,
Button: true,
SubgraphBreadcrumbItem: true
}
}
})
}
describe('SubgraphBreadcrumb', () => {
beforeEach(() => {
canvasState.linearMode = false
})
it('renders the workflow actions dropdown when not in linear mode', () => {
renderBreadcrumb()
expect(screen.getByTestId('wad')).toBeInTheDocument()
})
it('hides the workflow actions dropdown in linear mode', () => {
canvasState.linearMode = true
renderBreadcrumb()
expect(screen.queryByTestId('wad')).not.toBeInTheDocument()
})
})

View File

@@ -14,7 +14,10 @@
'--p-breadcrumb-icon-width': `${ICON_WIDTH}px`
}"
>
<WorkflowActionsDropdown source="breadcrumb_subgraph_menu_selected" />
<WorkflowActionsDropdown
v-if="!canvasStore.linearMode"
source="breadcrumb_subgraph_menu_selected"
/>
<Button
v-if="isInSubgraph"
class="back-button pointer-events-auto ml-1.5 size-8 shrink-0 border border-transparent bg-transparent p-0 transition-all hover:rounded-lg hover:border-interface-stroke hover:bg-comfy-menu-bg"
@@ -71,6 +74,7 @@ const ICON_WIDTH = 20
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const canvasStore = useCanvasStore()
const breadcrumbRef = ref<InstanceType<typeof Breadcrumb>>()
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const isBlueprint = computed(() =>

View File

@@ -0,0 +1,222 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { ViewMode } from '@/utils/appMode'
import WorkflowActionsDropdown from './WorkflowActionsDropdown.vue'
const spies = vi.hoisted(() => ({
execute: vi.fn(),
trackUiButtonClicked: vi.fn(),
markAsSeen: vi.fn()
}))
const viewState = vi.hoisted(() => ({
viewMode: 'graph' as ViewMode,
displayViewMode: 'graph' as ViewMode
}))
vi.mock('@/stores/appModeStore', async () => {
const { computed, reactive } = await import('vue')
return {
useAppModeStore: () =>
reactive({
viewMode: computed(() => viewState.viewMode),
displayViewMode: computed(() => viewState.displayViewMode)
})
}
})
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: spies.execute, commands: [] })
}))
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({
getKeybindingByCommandId: () => ({ combo: { toString: () => 'Ctrl+L' } })
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackUiButtonClicked: spies.trackUiButtonClicked })
}))
vi.mock('@/composables/useWorkflowActionsMenu', async () => {
const { ref } = await import('vue')
return { useWorkflowActionsMenu: () => ({ menuItems: ref([]) }) }
})
vi.mock('@/composables/useNewMenuItemIndicator', async () => {
const { ref } = await import('vue')
return {
useNewMenuItemIndicator: () => ({
hasUnseenItems: ref(true),
markAsSeen: spies.markAsSeen
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { shortcutSuffix: ' ({shortcut})' },
breadcrumbsMenu: {
graph: 'Graph',
app: 'App',
enterNodeGraph: 'Enter node graph',
enterAppMode: 'Enter app mode',
workflowActions: 'Workflow actions',
activeModeWorkflowActions: '{mode} mode, workflow actions'
}
}
}
})
function renderDropdown() {
const user = userEvent.setup()
const result = render(WorkflowActionsDropdown, {
props: { source: 'test' },
global: {
plugins: [i18n],
directives: { tooltip: {} },
stubs: {
DropdownMenuRoot: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
WorkflowActionsList: true
}
}
})
return { ...result, user }
}
describe('WorkflowActionsDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
viewState.viewMode = 'graph'
viewState.displayViewMode = 'graph'
})
it('keeps the active segment label in its accessible name alongside the actions label', () => {
renderDropdown()
// Graph is the active segment, so its name must contain the visible "Graph"
// label (label-in-name) while still matching the "Workflow actions" trigger.
const active = screen.getByRole('button', { name: /workflow actions/ })
expect(active).toHaveAttribute('aria-label', 'Graph mode, workflow actions')
})
it('labels the inactive segment with its switch action only', () => {
renderDropdown()
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
expect(inactive).toHaveAttribute('aria-label', 'Enter app mode')
})
it('flips the segment roles when app mode is active', () => {
viewState.viewMode = 'app'
viewState.displayViewMode = 'app'
renderDropdown()
const active = screen.getByRole('button', { name: /workflow actions/ })
expect(active).toHaveAttribute('aria-label', 'App mode, workflow actions')
expect(
screen.getByRole('button', { name: 'Enter node graph' })
).toHaveAttribute('aria-label', 'Enter node graph')
})
it('derives the active segment from the real mode, not the lagged display mode', () => {
// Mid-animation: the mode has flipped to app but the display still lags.
viewState.viewMode = 'app'
viewState.displayViewMode = 'graph'
renderDropdown()
const active = screen.getByRole('button', { name: /workflow actions/ })
expect(active).toHaveAttribute('aria-label', 'App mode, workflow actions')
expect(
screen.getByRole('button', { name: 'Enter node graph' })
).toBeInTheDocument()
})
it('carries the popup semantics only on the active segment', () => {
renderDropdown()
const active = screen.getByRole('button', { name: /workflow actions/ })
expect(active).toHaveAttribute('aria-haspopup', 'menu')
expect(active).toHaveAttribute('aria-expanded', 'false')
expect(
screen.getByRole('button', { name: 'Enter app mode' })
).not.toHaveAttribute('aria-haspopup')
})
it('toggles the view mode when the inactive segment is clicked', async () => {
const { user } = renderDropdown()
await user.click(screen.getByRole('button', { name: 'Enter app mode' }))
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
metadata: { source: 'test' }
})
})
it('opens the menu instead of toggling the mode when the active segment is clicked', async () => {
const { user } = renderDropdown()
const active = screen.getByRole('button', { name: /workflow actions/ })
await user.click(active)
expect(spies.execute).not.toHaveBeenCalled()
expect(active).toHaveAttribute('aria-expanded', 'true')
expect(spies.markAsSeen).toHaveBeenCalled()
expect(spies.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'test',
element_group: 'workflow_actions'
})
})
it('closes the menu when the open trigger is clicked again', async () => {
const { user } = renderDropdown()
const active = screen.getByRole('button', { name: /workflow actions/ })
await user.click(active)
await user.click(active)
expect(active).toHaveAttribute('aria-expanded', 'false')
})
it('switches mode when the inactive segment is activated by keyboard', async () => {
const { user } = renderDropdown()
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
inactive.focus()
await user.keyboard('{Enter}')
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
metadata: { source: 'test' }
})
})
it('does not switch mode when the active segment is activated by keyboard', async () => {
const { user } = renderDropdown()
const active = screen.getByRole('button', { name: /workflow actions/ })
active.focus()
await user.keyboard('{Enter}')
expect(spies.execute).not.toHaveBeenCalled()
})
it('opens the menu on ArrowDown on the active segment', async () => {
const { user } = renderDropdown()
const active = screen.getByRole('button', { name: /workflow actions/ })
active.focus()
await user.keyboard('{ArrowDown}')
expect(active).toHaveAttribute('aria-expanded', 'true')
})
})

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
DropdownMenuContent,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
DropdownMenuRoot
} from 'reka-ui'
import { ref } from 'vue'
import type { FocusOutsideEvent, PointerDownOutsideEvent } from 'reka-ui'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
@@ -14,8 +15,21 @@ import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useCommandStore } from '@/stores/commandStore'
import type { ViewMode } from '@/utils/appMode'
interface ViewModeSegment {
mode: ViewMode
icon: string
label: string
switchLabel: string
switchTooltip: string
/** Truth: drives behavior and aria. Flips as soon as the mode changes. */
active: boolean
/** Frame-lagged mirror of {@link active}: drives the morph styling/order. */
displayActive: boolean
}
const { source, align = 'start' } = defineProps<{
source: string
@@ -23,46 +37,120 @@ const { source, align = 'start' } = defineProps<{
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const keybindingStore = useKeybindingStore()
const dropdownOpen = ref(false)
const appModeStore = useAppModeStore()
const { menuItems } = useWorkflowActionsMenu(
() => useCommandStore().execute('Comfy.RenameWorkflow'),
{ isRoot: true }
)
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
() => menuItems.value
)
function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source,
element_group: 'workflow_actions'
})
}
}
function toggleModeTooltip() {
const label = canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
const toggleShortcut = computed(() => {
const shortcut = keybindingStore
.getKeybindingByCommandId('Comfy.ToggleLinear')
?.combo.toString()
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
return shortcut ? t('g.shortcutSuffix', { shortcut }) : ''
})
const segments = computed<ViewModeSegment[]>(() =>
(
[
{
mode: 'graph',
icon: 'icon-[comfy--workflow]',
label: t('breadcrumbsMenu.graph'),
switchLabel: t('breadcrumbsMenu.enterNodeGraph'),
switchTooltip:
t('breadcrumbsMenu.enterNodeGraph') + toggleShortcut.value
},
{
mode: 'app',
icon: 'icon-[lucide--panels-top-left]',
label: t('breadcrumbsMenu.app'),
switchLabel: t('breadcrumbsMenu.enterAppMode'),
switchTooltip: t('breadcrumbsMenu.enterAppMode') + toggleShortcut.value
}
] as const
).map((seg) => ({
...seg,
active: appModeStore.viewMode === seg.mode,
displayActive: appModeStore.displayViewMode === seg.mode
}))
)
// Display-inactive segment first (left), display-active last (right). On mode
// switch the array reorders and TransitionGroup FLIP-animates the keyed nodes
// to their new spots.
const orderedSegments = computed(() => {
const [graph, app] = segments.value
return graph.displayActive ? [app, graph] : [graph, app]
})
const toggleContainer = useTemplateRef<HTMLDivElement>('toggleContainer')
// The active segment is the only element carrying popup semantics, which makes
// this a stable, markup-derived way to find it.
function activeSegmentElement() {
return (
toggleContainer.value?.querySelector<HTMLElement>(
'[aria-haspopup="menu"]'
) ?? undefined
)
}
function toggleLinearMode() {
function toggleDropdown() {
dropdownOpen.value = !dropdownOpen.value
if (!dropdownOpen.value) return
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source,
element_group: 'workflow_actions'
})
}
function switchMode() {
dropdownOpen.value = false
void useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source }
})
}
function onSegmentClick(seg: ViewModeSegment) {
if (seg.active) toggleDropdown()
else switchMode()
}
// Match the stock dropdown trigger: ArrowDown on the trigger opens the menu.
function onSegmentKeydown(seg: ViewModeSegment, e: KeyboardEvent) {
if (!seg.active || e.key !== 'ArrowDown') return
e.preventDefault()
if (!dropdownOpen.value) toggleDropdown()
}
// Reimplements the two trigger-element behaviors of a stock DropdownMenuTrigger
// (which this component cannot use without breaking the FLIP morph): a click on
// the open menu's trigger toggles it closed instead of dismiss-then-reopen, and
// focus returns to the trigger on close unless the user interacted elsewhere.
let interactedOutside = false
function onInteractOutside(event: PointerDownOutsideEvent | FocusOutsideEvent) {
const target = event.target
if (target instanceof Node && activeSegmentElement()?.contains(target)) {
event.preventDefault()
return
}
interactedOutside = true
}
function onCloseAutoFocus(event: Event) {
event.preventDefault()
if (!interactedOutside) activeSegmentElement()?.focus()
interactedOutside = false
}
const tooltipPt = {
root: {
style: {
@@ -75,82 +163,97 @@ const tooltipPt = {
style: { whiteSpace: 'nowrap' }
},
arrow: {
class: '!left-[16px]'
style: { left: '16px' }
}
}
</script>
<template>
<DropdownMenuRoot
v-model:open="dropdownOpen"
:modal="false"
@update:open="handleOpen"
>
<slot name="button" :has-unseen-items="hasUnseenItems">
<div
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
<DropdownMenuRoot v-model:open="dropdownOpen" :modal="false">
<div
ref="toggleContainer"
data-testid="view-mode-toggle"
class="group pointer-events-auto relative inline-block rounded-lg bg-base-background p-1"
:data-state="dropdownOpen ? 'open' : 'closed'"
>
<TransitionGroup
tag="div"
move-class="transition-[background-color,color,transform] duration-200"
class="flex items-center gap-1"
>
<Button
v-for="seg in orderedSegments"
:key="seg.mode"
v-tooltip.bottom="{
value: toggleModeTooltip(),
value: seg.active
? t('breadcrumbsMenu.workflowActions')
: seg.switchTooltip,
showDelay: 300,
hideDelay: 300,
pt: tooltipPt
pt: seg.active ? undefined : tooltipPt
}"
type="button"
variant="textonly"
size="unset"
:aria-label="
canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
seg.active
? t('breadcrumbsMenu.activeModeWorkflowActions', {
mode: seg.label
})
: seg.switchLabel
"
variant="base"
class="m-1"
@pointerdown.stop
@click="toggleLinearMode"
:aria-haspopup="seg.active ? 'menu' : undefined"
:aria-expanded="seg.active ? dropdownOpen : undefined"
:class="
cn(
'relative flex h-8 items-center gap-0 rounded-md font-normal transition-[background-color,color,transform] duration-200',
seg.displayActive
? 'bg-secondary-background pr-2 pl-2.5 text-base-foreground group-data-[state=open]:bg-secondary-background-hover group-data-[state=open]:shadow-interface hover:bg-secondary-background'
: 'w-8 justify-center bg-transparent text-muted-foreground hover:bg-secondary-background hover:text-base-foreground'
)
"
@click="onSegmentClick(seg)"
@keydown="onSegmentKeydown(seg, $event)"
>
<i
class="size-4"
<i :class="cn('size-4 shrink-0', seg.icon)" aria-hidden="true" />
<span
:class="
canvasStore.linearMode
? 'icon-[lucide--panels-top-left]'
: 'icon-[comfy--workflow]'
cn(
'grid transition-[grid-template-columns,opacity] duration-200',
seg.displayActive
? 'ml-1.5 grid-cols-[1fr] opacity-100'
: 'grid-cols-[0fr] opacity-0'
)
"
>
<span
class="flex min-w-0 items-center overflow-hidden text-sm leading-none whitespace-nowrap"
>
{{ seg.label }}
<i
class="ml-1 icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</span>
</span>
<span
v-if="seg.active && hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
<DropdownMenuTrigger as-child>
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
showDelay: 300,
hideDelay: 300
}"
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<span>{{
canvasStore.linearMode
? t('breadcrumbsMenu.app')
: t('breadcrumbsMenu.graph')
}}</span>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
/>
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</DropdownMenuTrigger>
</div>
</slot>
</TransitionGroup>
</div>
<DropdownMenuPortal>
<DropdownMenuContent
:align
:side-offset="5"
:aria-label="t('breadcrumbsMenu.workflowActions')"
:reference="toggleContainer ?? undefined"
:side-offset="8"
:collision-padding="10"
class="z-1000 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
@interact-outside="onInteractOutside"
@close-auto-focus="onCloseAutoFocus"
>
<WorkflowActionsList :items="menuItems" />
</DropdownMenuContent>

View File

@@ -18,8 +18,8 @@
</div>
</div>
</template>
<template v-if="showUI && !isBuilderMode" #side-toolbar>
<SideToolbar />
<template #side-toolbar>
<SideToolbar v-if="showUI && !isBuilderMode && !linearMode" />
</template>
<template v-if="showUI" #side-bar-panel>
<div

View File

@@ -0,0 +1,195 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComponentProps } from 'vue-component-type-helpers'
import { createI18n } from 'vue-i18n'
import SideToolbar from './SideToolbar.vue'
interface TestTab {
id: string
icon: string
tooltip: string
label: string
title: string
}
const spies = vi.hoisted(() => ({
trackUiButtonClicked: vi.fn(),
toggleAssets: vi.fn()
}))
const state = vi.hoisted(() => ({
isMultiUserServer: false,
sidebarTabs: [] as TestTab[],
activeSidebarTab: null as { id: string } | null
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false,
isDesktop: false,
isNightly: false
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({
getSidebarTabs: () => state.sidebarTabs,
sidebarTab: { activeSidebarTab: state.activeSidebarTab }
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) => {
if (key === 'Comfy.Sidebar.Size') return 'large'
if (key === 'Comfy.Sidebar.Location') return 'left'
return 'floating'
}
})
}))
vi.mock('@/stores/userStore', () => ({
useUserStore: () => ({ isMultiUserServer: state.isMultiUserServer })
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
commands: [
{ id: 'Workspace.ToggleSidebarTab.assets', function: spies.toggleAssets }
]
})
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ canvas: null })
}))
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({ getKeybindingByCommandId: () => undefined })
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackUiButtonClicked: spies.trackUiButtonClicked })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} }
})
type SideToolbarProps = ComponentProps<typeof SideToolbar>
function renderToolbar(props: SideToolbarProps = {}) {
return render(SideToolbar, {
props,
global: {
plugins: [PrimeVue, i18n],
directives: { tooltip: Tooltip },
stubs: {
ComfyMenuButton: { template: '<div />' },
SidebarTemplatesButton: { template: '<div />' },
SidebarLogoutIcon: { template: '<div data-testid="logout" />' },
SidebarHelpCenterIcon: { template: '<div />' },
SidebarSettingsButton: { template: '<div />' },
HelpCenterPopups: { template: '<div />' },
SidebarBottomPanelToggleButton: {
template: '<div data-testid="bottom-panel-toggle" />'
},
SidebarShortcutsToggleButton: {
template: '<div data-testid="shortcuts-toggle" />'
}
}
}
})
}
const assetsTab: TestTab = {
id: 'assets',
icon: 'pi pi-image',
tooltip: 'Assets',
label: 'Assets',
title: 'Assets'
}
const workflowsTab: TestTab = {
id: 'workflows',
icon: 'pi pi-folder',
tooltip: 'Workflows',
label: 'Workflows',
title: 'Workflows'
}
describe('SideToolbar', () => {
beforeEach(() => {
vi.clearAllMocks()
state.isMultiUserServer = false
state.sidebarTabs = [assetsTab, workflowsTab]
state.activeSidebarTab = null
})
it('renders only the tabs listed in visibleTabIds', () => {
renderToolbar({ visibleTabIds: ['assets'] })
expect(screen.getByRole('button', { name: 'Assets' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Workflows' })
).not.toBeInTheDocument()
})
it('renders all sidebar tabs when visibleTabIds is omitted', () => {
renderToolbar()
expect(screen.getByRole('button', { name: 'Assets' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Workflows' })
).toBeInTheDocument()
})
it('marks the toolbar as connected when forceConnected is true', () => {
renderToolbar({ forceConnected: true })
// connected-sidebar is a behavioral hook: it drives the global
// :root:has() sidebar width variables.
expect(screen.getByTestId('side-toolbar')).toHaveClass('connected-sidebar')
})
it('shows the shortcuts and bottom panel toggles by default', () => {
renderToolbar()
expect(screen.getByTestId('shortcuts-toggle')).toBeInTheDocument()
expect(screen.getByTestId('bottom-panel-toggle')).toBeInTheDocument()
})
it('hides the shortcuts and bottom panel toggles when hideWorkspaceToggles is set', () => {
renderToolbar({ hideWorkspaceToggles: true })
expect(screen.queryByTestId('shortcuts-toggle')).not.toBeInTheDocument()
expect(screen.queryByTestId('bottom-panel-toggle')).not.toBeInTheDocument()
})
it('reports telemetry and runs the toggle command when a tab is clicked', async () => {
const user = userEvent.setup()
renderToolbar({ visibleTabIds: ['assets'] })
await user.click(screen.getByRole('button', { name: 'Assets' }))
expect(spies.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'sidebar_tab_assets_media_selected',
element_group: 'sidebar'
})
expect(spies.toggleAssets).toHaveBeenCalled()
})
it('renders the logout icon only on a multi-user server', () => {
const { unmount } = renderToolbar()
expect(screen.queryByTestId('logout')).not.toBeInTheDocument()
unmount()
state.isMultiUserServer = true
renderToolbar()
expect(screen.getByTestId('logout')).toBeInTheDocument()
})
})

View File

@@ -42,8 +42,14 @@
:is-small="isSmall"
/>
<SidebarHelpCenterIcon :is-small="isSmall" />
<SidebarBottomPanelToggleButton v-if="!isCloud" :is-small="isSmall" />
<SidebarShortcutsToggleButton :is-small="isSmall" />
<SidebarBottomPanelToggleButton
v-if="!isCloud && !hideWorkspaceToggles"
:is-small="isSmall"
/>
<SidebarShortcutsToggleButton
v-if="!hideWorkspaceToggles"
:is-small="isSmall"
/>
<SidebarSettingsButton :is-small="isSmall" />
</div>
</div>
@@ -89,6 +95,16 @@ import SidebarIcon from './SidebarIcon.vue'
import SidebarLogoutIcon from './SidebarLogoutIcon.vue'
import SidebarTemplatesButton from './SidebarTemplatesButton.vue'
const {
visibleTabIds,
forceConnected = false,
hideWorkspaceToggles = false
} = defineProps<{
visibleTabIds?: string[]
forceConnected?: boolean
hideWorkspaceToggles?: boolean
}>()
const NightlySurveyController =
isNightly && !isCloud && !isDesktop
? defineAsyncComponent(
@@ -115,12 +131,18 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
const sidebarStyle = computed(() => settingStore.get('Comfy.Sidebar.Style'))
const isConnected = computed(
() =>
forceConnected ||
selectedTab.value ||
isOverflowing.value ||
sidebarStyle.value === 'connected'
)
const tabs = computed(() => workspaceStore.getSidebarTabs())
const tabs = computed(() => {
const all = workspaceStore.getSidebarTabs()
return visibleTabIds
? all.filter((tab) => visibleTabIds.includes(tab.id))
: all
})
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
/**

View File

@@ -0,0 +1,150 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SidebarHelpCenterIcon from './SidebarHelpCenterIcon.vue'
const typeformState = vi.hoisted(() => ({
typeformError: false,
isValidTypeformId: true,
typeformId: 'jmmzmlKw'
}))
const canvasState = vi.hoisted(() => ({ linearMode: true }))
const helpCenterSpies = vi.hoisted(() => ({ toggleHelpCenter: vi.fn() }))
vi.mock('@/platform/surveys/useTypeformEmbed', async () => {
const { computed } = await import('vue')
return {
useTypeformEmbed: () => ({
typeformError: computed(() => typeformState.typeformError),
isValidTypeformId: computed(() => typeformState.isValidTypeformId),
typeformId: computed(() => typeformState.typeformId)
})
}
})
vi.mock('@/composables/useHelpCenter', async () => {
const { ref } = await import('vue')
return {
useHelpCenter: () => ({
shouldShowRedDot: ref(false),
toggleHelpCenter: helpCenterSpies.toggleHelpCenter
})
}
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: () => 'left' })
}))
vi.mock('@/renderer/core/canvas/canvasStore', async () => {
const { computed, reactive } = await import('vue')
return {
useCanvasStore: () =>
reactive({ linearMode: computed(() => canvasState.linearMode) })
}
})
const FEEDBACK_LOAD_ERROR =
'Failed to load feedback form. Please try again later.'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
menu: { help: 'Help' },
sideToolbar: { helpCenter: 'Help Center' },
linearMode: {
giveFeedback: 'Give feedback',
feedbackLoadError: FEEDBACK_LOAD_ERROR
}
}
}
})
function renderIcon() {
const user = userEvent.setup()
const result = render(SidebarHelpCenterIcon, {
props: { isSmall: false },
global: {
plugins: [i18n],
directives: { tooltip: {} },
stubs: {
Popover: {
template: '<div><slot name="button" /><slot /></div>'
}
}
}
})
return { ...result, user }
}
describe('SidebarHelpCenterIcon', () => {
beforeEach(() => {
vi.clearAllMocks()
typeformState.typeformError = false
typeformState.isValidTypeformId = true
canvasState.linearMode = true
})
it('mounts the Typeform embed container when the id is valid and loads', () => {
const { container } = renderIcon()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
expect(container.querySelector('[data-tf-widget]')).not.toBeNull()
expect(screen.queryByText(FEEDBACK_LOAD_ERROR)).not.toBeInTheDocument()
})
it('shows the localized fallback instead of the embed when loading fails', () => {
typeformState.typeformError = true
const { container } = renderIcon()
expect(screen.getByText(FEEDBACK_LOAD_ERROR)).toBeInTheDocument()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
expect(container.querySelector('[data-tf-widget]')).toBeNull()
})
it('shows the localized fallback when the form id is invalid', () => {
typeformState.isValidTypeformId = false
const { container } = renderIcon()
expect(screen.getByText(FEEDBACK_LOAD_ERROR)).toBeInTheDocument()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
expect(container.querySelector('[data-tf-widget]')).toBeNull()
})
it('does not open the help center from the feedback button in app mode', async () => {
const { user } = renderIcon()
await user.click(screen.getByRole('button', { name: 'Give feedback' }))
expect(helpCenterSpies.toggleHelpCenter).not.toHaveBeenCalled()
})
it('shows the help center button instead of the feedback popover in graph mode', () => {
canvasState.linearMode = false
const { container } = renderIcon()
expect(
screen.getByRole('button', { name: 'Help Center' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Give feedback' })
).not.toBeInTheDocument()
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- attribute hook: the embed target has no ARIA role
expect(container.querySelector('[data-tf-widget]')).toBeNull()
})
it('toggles the help center on click in graph mode', async () => {
canvasState.linearMode = false
const { user } = renderIcon()
await user.click(screen.getByRole('button', { name: 'Help Center' }))
expect(helpCenterSpies.toggleHelpCenter).toHaveBeenCalled()
})
})

View File

@@ -1,5 +1,34 @@
<template>
<Popover
v-if="linearMode"
:side="sidebarOnLeft ? 'right' : 'left'"
:side-offset="8"
>
<template #button>
<SidebarIcon
icon="pi pi-question-circle"
class="comfy-help-center-btn"
data-testid="help-center-button"
:label="$t('menu.help')"
:tooltip="$t('linearMode.giveFeedback')"
:is-small="isSmall"
/>
</template>
<div
v-if="typeformError || !isValidTypeformId"
class="text-danger p-4 text-sm"
>
{{ $t('linearMode.feedbackLoadError') }}
</div>
<div
v-else
ref="feedbackRef"
data-tf-auto-resize
:data-tf-widget="typeformId"
/>
</Popover>
<SidebarIcon
v-else
icon="pi pi-question-circle"
class="comfy-help-center-btn"
data-testid="help-center-button"
@@ -13,13 +42,34 @@
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
import { useHelpCenter } from '@/composables/useHelpCenter'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTypeformEmbed } from '@/platform/surveys/useTypeformEmbed'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import SidebarIcon from './SidebarIcon.vue'
const APP_MODE_FEEDBACK_TYPEFORM_ID = 'jmmzmlKw'
defineProps<{
isSmall: boolean
}>()
const { shouldShowRedDot, toggleHelpCenter } = useHelpCenter()
const { linearMode } = storeToRefs(useCanvasStore())
const settingStore = useSettingStore()
const sidebarOnLeft = computed(
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
)
const feedbackRef = useTemplateRef<HTMLDivElement>('feedbackRef')
const { typeformError, isValidTypeformId, typeformId } = useTypeformEmbed(
feedbackRef,
APP_MODE_FEEDBACK_TYPEFORM_ID
)
</script>

View File

@@ -0,0 +1,207 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import AppsSidebarTab from './AppsSidebarTab.vue'
const execute = vi.hoisted(() => vi.fn())
const workflowStoreState = vi.hoisted(() => ({
persistedWorkflows: [] as ComfyWorkflow[]
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const { ComfyWorkflow } =
await import('@/platform/workflow/management/stores/comfyWorkflow')
return {
ComfyWorkflow,
useWorkflowStore: () => ({
get workflows() {
return workflowStoreState.persistedWorkflows
},
get persistedWorkflows() {
return workflowStoreState.persistedWorkflows
},
bookmarkedWorkflows: [],
openWorkflows: [],
activeWorkflow: undefined,
isSyncLoading: false,
syncWorkflows: vi.fn()
}),
useWorkflowBookmarkStore: () => ({ loadBookmarks: vi.fn() })
}
})
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({})
}))
vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
useSearchQueryTracking: () => undefined
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({ shiftDown: false })
}))
vi.mock('@/composables/useAppMode', async () => {
const { computed } = await import('vue')
return { useAppMode: () => ({ isAppMode: computed(() => true) }) }
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: () => undefined })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
beta: 'Beta',
refresh: 'Refresh',
searchPlaceholder: 'Search {subject}'
},
sideToolbar: {
workflowTab: {
workflowTreeType: {
open: 'Open',
bookmarks: 'Bookmarks',
browse: 'Browse'
}
}
},
linearMode: {
appModeToolbar: {
apps: 'Apps',
create: 'Create',
createApp: 'Create app',
appsEmptyMessage: 'No apps yet',
appsEmptyMessageAction: 'Create one to get started'
}
}
}
}
})
const noResultsPlaceholderStub = {
props: ['buttonLabel'],
emits: ['action'],
template: '<button @click="$emit(\'action\')">{{ buttonLabel }}</button>'
}
function renderTab({ hasResults = true }: { hasResults?: boolean } = {}) {
const user = userEvent.setup()
const result = render(AppsSidebarTab, {
global: {
plugins: [i18n],
stubs: {
BaseWorkflowsSidebarTab: {
template: `<div><slot name="header-actions" :has-results="${hasResults}" /><slot name="empty-state" /></div>`
},
NoResultsPlaceholder: noResultsPlaceholderStub
}
}
})
return { ...result, user }
}
async function makeWorkflow(path: string): Promise<ComfyWorkflow> {
const { ComfyWorkflow } =
await import('@/platform/workflow/management/stores/comfyWorkflow')
return new ComfyWorkflow({ path, modified: 0, size: 1 })
}
function renderTabWithRealBase() {
const user = userEvent.setup()
const result = render(AppsSidebarTab, {
global: {
plugins: [i18n],
directives: { tooltip: {} },
stubs: {
SidebarTabTemplate: {
template:
'<div><slot name="alt-title" /><slot name="tool-buttons" /><slot name="header" /><slot name="body" /></div>'
},
SidebarTopArea: { template: '<div><slot /></div>' },
SearchInput: { template: '<input />', methods: { focus() {} } },
TreeExplorer: { template: '<div data-testid="tree-explorer" />' },
NoResultsPlaceholder: noResultsPlaceholderStub
}
}
})
return { ...result, user }
}
describe('AppsSidebarTab', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowStoreState.persistedWorkflows = []
})
it('shows the create action only when there are results', () => {
const { unmount } = renderTab({ hasResults: false })
expect(
screen.queryByRole('button', { name: 'Create' })
).not.toBeInTheDocument()
unmount()
renderTab({ hasResults: true })
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
})
it('runs the new-workflow command when the create action is clicked', async () => {
const { user } = renderTab({ hasResults: true })
await user.click(screen.getByRole('button', { name: 'Create' }))
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
})
it('runs the new-workflow command from the empty-state action', async () => {
const { user } = renderTab({ hasResults: false })
await user.click(screen.getByRole('button', { name: 'Create app' }))
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
})
describe('with the real workflows tab', () => {
it('counts only app workflows as results', async () => {
workflowStoreState.persistedWorkflows = [
await makeWorkflow('workflows/my-app.app.json'),
await makeWorkflow('workflows/regular.json')
]
renderTabWithRealBase()
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Create app' })
).not.toBeInTheDocument()
})
it('shows the empty state when no app workflows exist', async () => {
workflowStoreState.persistedWorkflows = [
await makeWorkflow('workflows/regular.json')
]
renderTabWithRealBase()
expect(
screen.queryByRole('button', { name: 'Create' })
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Create app' })
).toBeInTheDocument()
})
})
})

View File

@@ -13,18 +13,25 @@
{{ $t('g.beta') }}
</span>
</template>
<template #header-actions="{ hasResults }">
<Button
v-if="hasResults"
variant="secondary"
size="md"
@click="createApp"
>
<i class="icon-[lucide--plus] size-4" aria-hidden="true" />
{{ $t('linearMode.appModeToolbar.create') }}
</Button>
</template>
<template #empty-state>
<NoResultsPlaceholder
button-variant="secondary"
text-class="text-muted-foreground text-sm"
:message="
isAppMode
? $t('linearMode.appModeToolbar.appsEmptyMessage')
: `${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`
"
button-icon="icon-[lucide--hammer]"
:button-label="isAppMode ? undefined : $t('linearMode.buildAnApp')"
@action="enterAppMode"
:message="`${$t('linearMode.appModeToolbar.appsEmptyMessage')}\n${$t('linearMode.appModeToolbar.appsEmptyMessageAction')}`"
button-icon="icon-[lucide--plus]"
:button-label="$t('linearMode.appModeToolbar.createApp')"
@action="createApp"
/>
</template>
</BaseWorkflowsSidebarTab>
@@ -33,16 +40,17 @@
<script setup lang="ts">
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
import { useAppMode } from '@/composables/useAppMode'
import Button from '@/components/ui/button/Button.vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
const { isAppMode, setMode } = useAppMode()
const commandStore = useCommandStore()
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
return workflow.suffix === 'app.json'
}
function enterAppMode() {
setMode('app')
function createApp() {
void commandStore.execute('Comfy.NewBlankWorkflow')
}
</script>

View File

@@ -30,6 +30,10 @@
"
/>
</Button>
<slot
name="header-actions"
:has-results="filteredPersistedWorkflows.length > 0"
/>
</template>
<template #header>
<SidebarTopArea>

View File

@@ -1,46 +0,0 @@
<script setup lang="ts">
import { breakpointsTailwind, useBreakpoints, whenever } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
const { active = true } = defineProps<{
dataTfWidget: string
active?: boolean
}>()
const feedbackRef = useTemplateRef('feedbackRef')
const isMobile = useBreakpoints(breakpointsTailwind).smaller('md')
whenever(feedbackRef, () => {
const scriptEl = document.createElement('script')
scriptEl.src = '//embed.typeform.com/next/embed.js'
feedbackRef.value?.appendChild(scriptEl)
})
</script>
<template>
<Button
v-if="isMobile"
as="a"
:href="`https://form.typeform.com/to/${dataTfWidget}`"
target="_blank"
variant="inverted"
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-help] size-4" />
</Button>
<Popover v-else>
<template #button>
<Button
variant="inverted"
class="flex h-10 items-center justify-center gap-2.5 px-3 py-2"
v-bind="$attrs"
>
<i class="icon-[lucide--circle-help] size-4" />
</Button>
</template>
<div v-if="active" ref="feedbackRef" data-tf-auto-resize :data-tf-widget />
</Popover>
</template>

View File

@@ -0,0 +1,35 @@
<script setup lang="ts">
import {
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipRoot,
TooltipTrigger
} from 'reka-ui'
defineOptions({ inheritAttrs: false })
const { text, side = 'top' } = defineProps<{
text: string
side?: 'top' | 'right' | 'bottom' | 'left'
}>()
</script>
<template>
<TooltipProvider :delay-duration="300">
<TooltipRoot>
<TooltipTrigger as-child v-bind="$attrs">
<slot />
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
:side
:side-offset="5"
class="z-1700 rounded-md border border-border-subtle bg-base-background px-2 py-1 text-xs shadow-sm"
>
{{ text }}
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</TooltipProvider>
</template>

View File

@@ -3069,6 +3069,7 @@
"enterBuilderMode": "Build app",
"editBuilderMode": "Edit app",
"workflowActions": "Workflow actions",
"activeModeWorkflowActions": "{mode} mode, workflow actions",
"clearWorkflow": "Clear Workflow",
"deleteWorkflow": "Delete Workflow",
"deleteBlueprint": "Delete Blueprint",
@@ -3690,13 +3691,13 @@
},
"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",
"mobileControls": "Edit & Run",
"runCount": "Number of runs",
"generating": "Generating…",
"stopGeneration": "Stop generation",
"rerun": "Rerun",
"reuseParameters": "Reuse Parameters",
"downloadAll": "Download {count} assets from this run",
@@ -3705,7 +3706,6 @@
"emptyWorkflowExplanation": "Your workflow is empty. You need some nodes first to start building an app.",
"backToWorkflow": "Back to workflow",
"loadTemplate": "Load a template",
"cancelThisRun": "Cancel this run",
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"viewGraph": "View node graph",
@@ -3724,7 +3724,10 @@
"appBuilder": "App builder",
"apps": "Apps",
"appsEmptyMessage": "Saved apps will show up here.",
"appsEmptyMessageAction": "Click below to build your first app."
"appsEmptyMessageAction": "Click below to build your first app.",
"buildAnApp": "Build an app",
"create": "Create",
"createApp": "Create app"
},
"arrange": {
"noOutputs": "No outputs added yet",
@@ -3767,6 +3770,7 @@
"support": "contact our support",
"promptShow": "Show error report"
},
"feedbackLoadError": "Failed to load feedback form. Please try again later.",
"queue": {
"clickToClear": "Click to clear queue",
"clear": "Clear queue"

View File

@@ -1,6 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas, Positionable } from '@/lib/litegraph/src/litegraph'
@@ -8,9 +9,13 @@ import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
const { appModeState } = vi.hoisted(() => ({
appModeState: {} as { isAppMode: Ref<boolean> }
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
isAppMode: { value: false },
isAppMode: appModeState.isAppMode,
setMode: vi.fn()
})
}))
@@ -43,6 +48,7 @@ describe('useCanvasStore', () => {
let store: ReturnType<typeof useCanvasStore>
beforeEach(() => {
appModeState.isAppMode = ref(false)
setActivePinia(createTestingPinia({ stubActions: false }))
store = useCanvasStore()
vi.clearAllMocks()

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import { usePreferredReducedMotion } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import VideoPlayOverlay from '@/platform/assets/components/VideoPlayOverlay.vue'
import { computeFanLayout } from '@/renderer/extensions/linearMode/fanLayout'
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
import {
getMediaType,
mediaTypes
} from '@/renderer/extensions/linearMode/mediaTypes'
import { cn } from '@comfyorg/tailwind-utils'
// depth: recency rank, 0 = newest. total: number of cards in the fan.
const { card, depth, total } = defineProps<{
card: InProgressItem
depth: number
total: number
}>()
// Skip the drop-in entrance entirely when the user prefers reduced motion.
const reducedMotion = usePreferredReducedMotion()
const mounted = ref(reducedMotion.value === 'reduce')
onMounted(() => {
if (!mounted.value) requestAnimationFrame(() => (mounted.value = true))
})
const mediaType = computed(() =>
card.state === 'image' && card.output ? getMediaType(card.output) : 'images'
)
// The still image to show (latent preview, then final image output). Video and
// other media render from card.output directly.
const imageSrc = computed(() => {
if (card.state === 'latent') return card.latentPreviewUrl
if (card.state === 'image' && card.output && mediaType.value === 'images')
return card.output.url
return undefined
})
// Fade the image in once loaded so it never paints half-formed. A later src
// (latent -> final) swaps in place: the browser keeps the prior frame until the
// new one is ready, so it stays loaded.
const loaded = ref(false)
const layout = computed(() => computeFanLayout(depth, total))
// New cards drop in and scale up slightly on first mount.
const ENTER_DROP_PX = 22
const ENTER_SCALE = 0.94
// Positioning lives on this inner element, not the root: the fan's
// <TransitionGroup> rewrites the root's transform to measure moves, which would
// otherwise fling cards to the corner for a frame. The root only carries
// z-index and the fade in/out opacity.
const innerStyle = computed(() => {
const f = layout.value
const entering = !mounted.value
const y = entering ? f.y + ENTER_DROP_PX : f.y
const scale = f.scale * (entering ? ENTER_SCALE : 1)
return {
transform: `translate(-50%, -50%) translateX(${f.x}px) translateY(${y}px) rotate(${f.rotate}deg) scale(${scale})`,
opacity: f.opacity
}
})
</script>
<template>
<div class="absolute inset-0" :style="{ zIndex: layout.z }">
<div
class="gen-card absolute top-1/2 left-1/2 size-[min(46cqh,300px)] overflow-hidden rounded-2xl bg-secondary-background shadow-[0_24px_60px_-12px_rgba(0,0,0,0.65)] ring-1 ring-border-subtle transition-[transform,opacity] duration-620 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform"
:style="innerStyle"
>
<img
v-if="imageSrc"
:src="imageSrc"
alt=""
:class="
cn(
'size-full object-cover',
loaded ? 'opacity-100' : 'opacity-0',
reducedMotion !== 'reduce' && 'transition-opacity duration-300'
)
"
@load="loaded = true"
/>
<template v-else-if="mediaType === 'video' && card.output">
<video
class="size-full object-cover"
preload="metadata"
:src="card.output.url"
/>
<VideoPlayOverlay size="sm" />
</template>
<i
v-else-if="card.output"
:class="cn(mediaTypes[mediaType]?.iconClass, 'm-auto block size-12')"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,173 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { computed, ref } from 'vue'
import GeneratingCard from './GeneratingCard.vue'
import GeneratingScreen from './GeneratingScreen.vue'
import { GENERATING_CARD_LIMIT } from './linearOutputStore'
import type { InProgressItem } from './linearModeTypes'
const meta: Meta<typeof GeneratingScreen> = {
title: 'LinearMode/GeneratingScreen',
component: GeneratingScreen,
parameters: { layout: 'fullscreen' },
decorators: [
(story) => ({
components: { story },
template: `
<div class="h-screen w-screen bg-[var(--color-workspace-bg)]">
<story />
</div>
`
})
]
}
export default meta
type Story = StoryObj<typeof meta>
function swatch(seed: number): string {
const hue = (seed * 67) % 360
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="300"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="hsl(${hue} 60% 55%)"/><stop offset="100%" stop-color="hsl(${(hue + 40) % 360} 55% 30%)"/></linearGradient></defs><rect width="300" height="300" fill="url(#g)"/></svg>`
return `data:image/svg+xml,${encodeURIComponent(svg)}`
}
// Newest first, matching the store's generatingCards order.
const fanCards: InProgressItem[] = [
{
id: 'c5',
jobId: 'job',
seq: 5,
state: 'latent',
latentPreviewUrl: swatch(5)
},
{
id: 'c4',
jobId: 'job',
seq: 4,
state: 'latent',
latentPreviewUrl: swatch(4)
},
{
id: 'c3',
jobId: 'job',
seq: 3,
state: 'latent',
latentPreviewUrl: swatch(3)
},
{
id: 'c2',
jobId: 'job',
seq: 2,
state: 'latent',
latentPreviewUrl: swatch(2)
},
{
id: 'c1',
jobId: 'job',
seq: 1,
state: 'latent',
latentPreviewUrl: swatch(1)
}
]
function fanned(cards: InProgressItem[]) {
const total = cards.length
return cards.map((card, depth) => ({ card, depth, total })).reverse()
}
// The real screen, wired to the store (empty fan): shows the ambient glow,
// status text, progress bar and Stop button.
export const Empty: Story = {}
// Composite preview of the popping-card fan over the ambient glow, using the
// same markup the screen builds around GeneratingCard.
export const Fan: Story = {
render: () => ({
components: { GeneratingCard },
setup: () => ({ cards: fanned(fanCards) }),
template: `
<div class="flex h-full w-full items-center justify-center @container-size">
<div class="relative flex h-[min(50cqh,320px)] w-[440px] items-center justify-center overflow-visible">
<div class="pointer-events-none absolute top-1/2 left-1/2 size-[min(150cqw,150cqh,760px)] -translate-1/2">
<span class="gen-glow" />
</div>
<div class="pointer-events-none absolute inset-0">
<GeneratingCard
v-for="{ card, depth, total } in cards"
:key="card.id"
:card="card"
:depth="depth"
:total="total"
/>
</div>
</div>
</div>
`
})
}
// Interactive harness for the fan: add cards to watch the pop-in entrance and
// reflow, remove the newest to watch it leave, and reset to clear. Adding past
// GENERATING_CARD_LIMIT evicts the oldest so the eviction fade is visible too.
export const Interactive: Story = {
render: () => ({
components: { GeneratingCard },
setup() {
const cards = ref<InProgressItem[]>([])
let seq = 0
function add() {
seq += 1
const next: InProgressItem = {
id: `c${seq}`,
jobId: 'job',
seq,
state: 'latent',
latentPreviewUrl: swatch(seq)
}
cards.value = [next, ...cards.value].slice(0, GENERATING_CARD_LIMIT)
}
function remove() {
cards.value = cards.value.slice(1)
}
function reset() {
cards.value = []
}
const fanCards = computed(() => {
const total = cards.value.length
return cards.value
.map((card, depth) => ({ card, depth, total }))
.reverse()
})
const btnClass =
'rounded-lg bg-secondary-background px-3 py-1.5 text-sm text-muted-foreground ring-1 ring-border-subtle transition-opacity hover:opacity-70'
return { fanCards, add, remove, reset, btnClass }
},
template: `
<div class="@container-size flex h-full w-full flex-col items-center justify-center gap-10">
<div class="flex gap-2">
<button :class="btnClass" @click="add">Add card</button>
<button :class="btnClass" @click="remove">Remove newest</button>
<button :class="btnClass" @click="reset">Reset</button>
</div>
<div class="relative flex h-[min(50cqh,320px)] w-[440px] items-center justify-center overflow-visible">
<div class="pointer-events-none absolute top-1/2 left-1/2 size-[min(150cqw,150cqh,760px)] -translate-1/2">
<span class="gen-glow" />
</div>
<TransitionGroup tag="div" name="genfan" class="pointer-events-none absolute inset-0">
<GeneratingCard
v-for="{ card, depth, total } in fanCards"
:key="card.id"
:card="card"
:depth="depth"
:total="total"
/>
</TransitionGroup>
</div>
</div>
`
})
}

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import { refThrottled } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import Button from '@/components/ui/button/Button.vue'
import GeneratingCard from '@/renderer/extensions/linearMode/GeneratingCard.vue'
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
defineEmits<{ stop: [] }>()
const { t } = useI18n()
const { executionStatusMessage } = useExecutionStatus()
const { totalPercent } = useQueueProgress()
const { generatingCards } = storeToRefs(useLinearOutputStore())
const statusMessage = computed(
() => executionStatusMessage.value ?? t('linearMode.generating')
)
// Throttle status text so it doesn't flicker as nodes execute in quick succession.
const displayStatus = refThrottled(statusMessage, 1000)
// Only cards with something to show belong in the fan; skeletons and latents
// without a preview are skipped. Each card fades its own image in on load.
function hasFanContent(card: InProgressItem): boolean {
if (card.state === 'image') return card.output != null
if (card.state === 'latent') return card.latentPreviewUrl != null
return false
}
// generatingCards is newest-first; render oldest-first so the newest paints
// last (on top).
const fanCards = computed(() => {
const cards = generatingCards.value.filter(hasFanContent)
const total = cards.length
return cards.map((card, depth) => ({ card, depth, total })).reverse()
})
</script>
<template>
<div class="flex size-full min-h-0 items-center justify-center px-6">
<div
class="@container-size relative flex size-full items-center justify-center"
>
<div
class="relative z-10 flex w-full max-w-[min(100%,440px)] flex-col items-center gap-8 px-4"
>
<div
class="relative flex h-[min(50cqh,320px)] w-full shrink-0 items-center justify-center overflow-visible"
>
<div
class="pointer-events-none absolute top-1/2 left-1/2 size-[min(150cqw,150cqh,760px)] -translate-1/2"
>
<span class="gen-glow" />
</div>
<TransitionGroup
tag="div"
name="genfan"
class="pointer-events-none absolute inset-0"
>
<GeneratingCard
v-for="{ card, depth, total } in fanCards"
:key="card.id"
:card
:depth
:total
/>
</TransitionGroup>
</div>
<div
class="relative flex w-full max-w-[min(100%,280px)] shrink-0 flex-col items-center gap-7"
>
<span
role="status"
aria-live="polite"
class="min-h-[18px] text-center text-[13px] leading-tight text-muted-foreground"
>
{{ displayStatus }}
</span>
<div
role="progressbar"
:aria-valuenow="Math.round(totalPercent)"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="t('linearMode.generating')"
class="relative h-0.5 w-full overflow-hidden rounded-full bg-secondary-background"
>
<div
data-testid="generating-progress"
class="h-full rounded-full bg-interface-panel-job-progress-primary transition-[width] duration-150 ease-linear"
:style="{ width: `${totalPercent}%` }"
/>
</div>
<Button
variant="destructive"
size="md"
data-testid="linear-cancel-run"
@click="$emit('stop')"
>
{{ t('linearMode.stopGeneration') }}
</Button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,23 +0,0 @@
<script setup lang="ts">
import { useExecutionStatus } from '@/renderer/extensions/linearMode/useExecutionStatus'
const { executionStatusMessage } = useExecutionStatus()
</script>
<template>
<div
class="sz-full flex min-h-0 flex-1 flex-col items-center justify-center gap-3"
>
<div class="flex h-full items-center justify-center">
<div
class="skeleton-shimmer aspect-square size-[min(50vw,50vh)] rounded-lg"
/>
</div>
<span
v-if="executionStatusMessage"
class="animate-pulse text-sm text-muted"
>
{{ executionStatusMessage }}
</span>
</div>
</template>

View File

@@ -1,42 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import TypeformPopoverButton from '@/components/ui/TypeformPopoverButton.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@comfyorg/tailwind-utils'
const { side, widgetId } = defineProps<{
side: 'left' | 'right'
widgetId: string
}>()
const { t } = useI18n()
const settingStore = useSettingStore()
const sidebarOnLeft = computed(
() => settingStore.get('Comfy.Sidebar.Location') === 'left'
)
const visible = computed(() => sidebarOnLeft.value === (side === 'left'))
</script>
<template>
<div
:class="
cn(
'flex items-center gap-2 self-end px-4 pb-4 text-nowrap text-base-foreground',
side === 'right' && 'flex-row-reverse',
!visible && 'invisible'
)
"
:aria-hidden="!visible || undefined"
>
<TypeformPopoverButton
:active="visible"
:data-tf-widget="widgetId"
:align="side === 'left' ? 'start' : 'end'"
/>
<div class="flex flex-col text-sm text-muted-foreground">
<span>{{ t('linearMode.beta') }}</span>
<span>{{ t('linearMode.giveFeedback') }}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,200 @@
import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createPinia } from 'pinia'
import { defineComponent } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import LinearPreview from './LinearPreview.vue'
import type { OutputSelection } from './linearModeTypes'
const appModeState = vi.hoisted(() => ({
isBuilderMode: false,
isArrangeMode: false
}))
const outputHistoryState = vi.hoisted(() => ({
isWorkflowActive: false
}))
const spies = vi.hoisted(() => ({
cancelActiveWorkflowJobs: vi.fn(),
deleteAssets: vi.fn(),
openShareDialog: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('@/composables/useAppMode', async () => {
const { computed } = await import('vue')
return {
useAppMode: () => ({
isBuilderMode: computed(() => appModeState.isBuilderMode),
isArrangeMode: computed(() => appModeState.isArrangeMode)
})
}
})
vi.mock('@/renderer/extensions/linearMode/useOutputHistory', async () => {
const { computed } = await import('vue')
return {
useOutputHistory: () => ({
allOutputs: () => [],
isWorkflowActive: computed(() => outputHistoryState.isWorkflowActive),
cancelActiveWorkflowJobs: spies.cancelActiveWorkflowJobs
})
}
})
vi.mock('@/platform/assets/composables/useMediaAssetActions', () => ({
useMediaAssetActions: () => ({ deleteAssets: spies.deleteAssets })
}))
vi.mock('@/scripts/app', () => ({
app: { rootGraph: { id: 'root' }, loadGraphData: vi.fn() }
}))
vi.mock('@/platform/workflow/sharing/composables/lazyShareDialog', () => ({
openShareDialog: spies.openShareDialog,
prefetchShareDialog: vi.fn()
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { download: 'Download', moreOptions: 'More Options' },
actionbar: { shareTooltip: 'Share workflow' },
linearMode: {
rerun: 'Rerun',
reuseParameters: 'Reuse Parameters',
cancelThisRun: 'Cancel this run',
deleteAllAssets: 'Delete all',
downloadAll: 'Download all'
}
}
}
})
function renderPreview(
props: { mobile?: boolean } = {},
emitSelection?: OutputSelection
) {
const user = userEvent.setup()
const outputHistoryStub = emitSelection
? defineComponent({
emits: ['update-selection'],
mounted() {
this.$emit('update-selection', emitSelection)
},
template: '<div data-testid="output-history" />'
})
: {
template: '<div data-testid="output-history" />'
}
const result = render(LinearPreview, {
props,
global: {
plugins: [i18n, createPinia()],
directives: { tooltip: {} },
stubs: {
ImagePreview: { template: '<div data-testid="image-preview" />' },
GeneratingScreen: {
emits: ['stop'],
template:
'<button data-testid="generating-screen" @click="$emit(\'stop\')" />'
},
LinearWelcome: { template: '<div data-testid="linear-welcome" />' },
LinearArrange: { template: '<div data-testid="linear-arrange" />' },
MediaOutputPreview: true,
OutputHistory: outputHistoryStub
}
}
})
return { ...result, user }
}
describe('LinearPreview', () => {
beforeEach(() => {
vi.clearAllMocks()
appModeState.isBuilderMode = false
appModeState.isArrangeMode = false
outputHistoryState.isWorkflowActive = false
})
it('renders the welcome screen and output history when idle', () => {
renderPreview()
expect(screen.getByTestId('linear-welcome')).toBeInTheDocument()
expect(screen.getByTestId('output-history')).toBeInTheDocument()
})
it('hides the output history in builder mode', () => {
appModeState.isBuilderMode = true
renderPreview()
expect(screen.queryByTestId('output-history')).not.toBeInTheDocument()
})
it('shows the arrange view in arrange mode', () => {
appModeState.isArrangeMode = true
renderPreview()
expect(screen.getByTestId('linear-arrange')).toBeInTheDocument()
expect(screen.queryByTestId('linear-welcome')).not.toBeInTheDocument()
})
it('shows the generating screen and cancels the run on stop while a workflow is active', async () => {
outputHistoryState.isWorkflowActive = true
const { user } = renderPreview()
expect(screen.getByTestId('linear-output-info')).toBeInTheDocument()
await user.click(screen.getByTestId('generating-screen'))
expect(spies.cancelActiveWorkflowJobs).toHaveBeenCalled()
})
it('disables the selection-dependent actions when nothing is selected', () => {
renderPreview()
expect(screen.getByRole('button', { name: 'Rerun' })).toBeDisabled()
expect(
screen.getByRole('button', { name: 'Reuse Parameters' })
).toBeDisabled()
expect(screen.getByRole('button', { name: 'More Options' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Download' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Share workflow' })).toBeEnabled()
})
it('opens the share dialog from the share button', async () => {
const { user } = renderPreview()
await user.click(screen.getByRole('button', { name: 'Share workflow' }))
expect(spies.openShareDialog).toHaveBeenCalled()
})
it('enables the asset actions and shows the latent image when a selection is made', async () => {
const asset: AssetItem = { id: 'a1', name: 'out.png', tags: [] }
const selection: OutputSelection = {
asset,
canShowPreview: true,
latentPreviewUrl: 'blob:preview'
}
renderPreview({}, selection)
const rerun = screen.getByRole('button', { name: 'Rerun' })
await waitFor(() => expect(rerun).toBeEnabled())
expect(
screen.getByRole('button', { name: 'Reuse Parameters' })
).toBeEnabled()
expect(screen.getByRole('button', { name: 'More Options' })).toBeEnabled()
expect(screen.getByTestId('image-preview')).toBeInTheDocument()
})
})

View File

@@ -5,46 +5,49 @@ import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import Tooltip from '@/components/ui/tooltip/Tooltip.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import {
openShareDialog,
prefetchShareDialog
} from '@/platform/workflow/sharing/composables/lazyShareDialog'
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
import GeneratingScreen from '@/renderer/extensions/linearMode/GeneratingScreen.vue'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
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 { app } from '@/scripts/app'
import type { ResultItemImpl } from '@/stores/queueStore'
import { cn } from '@comfyorg/tailwind-utils'
const { t } = useI18n()
const mediaActions = useMediaAssetActions()
const { isBuilderMode, isArrangeMode } = useAppMode()
const { allOutputs, isWorkflowActive, cancelActiveWorkflowJobs } =
useOutputHistory()
const { runButtonClick, mobile, typeformWidgetId } = defineProps<{
const { runButtonClick, mobile } = defineProps<{
runButtonClick?: (e: Event) => void
mobile?: boolean
typeformWidgetId?: string
}>()
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) {
@@ -73,45 +76,10 @@ async function rerun(e: Event) {
</script>
<template>
<section
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"
class="flex w-full justify-end gap-2 p-4 md:z-10"
>
<template v-if="selectedItem">
<Button size="md" @click="rerun">
{{ t('linearMode.rerun') }}
<i class="icon-[lucide--refresh-cw]" />
</Button>
<Button size="md" @click="() => loadWorkflow(selectedItem)">
{{ t('linearMode.reuseParameters') }}
<i class="icon-[lucide--list-restart]" />
</Button>
<div class="mx-1 border-r border-border-subtle" />
</template>
<Button
v-if="selectedOutput"
v-tooltip.top="t('g.download')"
size="icon"
:aria-label="t('g.download')"
@click="
() => {
if (selectedOutput?.url) downloadFile(selectedOutput.url)
}
"
>
<i class="icon-[lucide--download]" />
</Button>
<Button
v-if="isWorkflowActive && !selectedItem"
data-testid="linear-cancel-run"
variant="destructive"
@click="cancelActiveWorkflowJobs()"
>
<i class="icon-[lucide--x]" />
{{ t('linearMode.cancelThisRun') }}
</Button>
<Popover
v-if="selectedItem"
:entries="[
...(allOutputs(selectedItem).length > 1
? [
@@ -131,10 +99,78 @@ async function rerun(e: Event) {
command: () => mediaActions.deleteAssets(selectedItem!)
}
]"
/>
>
<template #button>
<Tooltip :text="t('g.moreOptions')">
<Button
variant="base"
size="icon"
:disabled="!selectedItem"
:aria-label="t('g.moreOptions')"
>
<i class="icon-[lucide--ellipsis]" />
</Button>
</Tooltip>
</template>
</Popover>
<Tooltip :text="t('linearMode.rerun')">
<Button
variant="base"
size="icon"
:disabled="!selectedItem"
:aria-label="t('linearMode.rerun')"
@click="rerun"
>
<i class="icon-[lucide--refresh-cw]" />
</Button>
</Tooltip>
<Tooltip :text="t('linearMode.reuseParameters')">
<Button
variant="base"
size="icon"
:disabled="!selectedItem"
:aria-label="t('linearMode.reuseParameters')"
@click="() => loadWorkflow(selectedItem)"
>
<i class="icon-[lucide--list-restart]" />
</Button>
</Tooltip>
<Tooltip :text="t('actionbar.shareTooltip')">
<Button
variant="base"
size="icon"
class="border border-solid border-border-default"
:aria-label="t('actionbar.shareTooltip')"
@click="
() => openShareDialog().catch(useErrorHandling().toastErrorHandler)
"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[comfy--send]" />
</Button>
</Tooltip>
<Tooltip :text="t('g.download')">
<Button
variant="inverted"
size="icon"
:disabled="!selectedOutput?.url"
:aria-label="t('g.download')"
@click="
() => {
if (selectedOutput?.url) downloadFile(selectedOutput.url)
}
"
>
<i class="icon-[lucide--download]" />
</Button>
</Tooltip>
</section>
<GeneratingScreen
v-if="isWorkflowActive"
@stop="cancelActiveWorkflowJobs()"
/>
<ImagePreview
v-if="canShowPreview && latentPreview"
v-else-if="canShowPreview && latentPreview"
:mobile
:src="latentPreview"
:show-size="false"
@@ -144,31 +180,11 @@ async function rerun(e: Event) {
:output="selectedOutput"
:mobile
/>
<LatentPreview v-else-if="showSkeleton || isWorkflowActive" />
<LinearArrange v-else-if="isArrangeMode" />
<LinearWelcome v-else />
<div
v-if="!mobile"
class="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
>
<LinearFeedback
v-if="typeformWidgetId"
side="left"
:widget-id="typeformWidgetId"
/>
<OutputHistory
v-if="!isBuilderMode"
class="z-10 min-w-0"
@update-selection="handleSelection"
/>
<LinearFeedback
v-if="typeformWidgetId"
side="right"
:widget-id="typeformWidgetId"
/>
</div>
<OutputHistory
v-else-if="!isBuilderMode"
v-if="!isBuilderMode"
:class="cn(!mobile && 'z-10 min-w-0')"
@update-selection="handleSelection"
/>
</template>

View File

@@ -162,7 +162,7 @@ function makeInProgressItem(
state: InProgressItem['state'] = 'skeleton',
opts?: Partial<InProgressItem>
): InProgressItem {
return { id, jobId: `job-${id}`, state, ...opts }
return { id, jobId: `job-${id}`, seq: 0, state, ...opts }
}
let activeResult: RenderResult | null = null
@@ -388,7 +388,7 @@ describe('OutputHistory', () => {
expect(lastEmission(result)).toEqual({ canShowPreview: true })
})
it('emits showSkeleton for in-progress skeleton item', async () => {
it('emits canShowPreview for in-progress skeleton item', async () => {
activeWorkflowInProgressItemsRef.value = [
makeInProgressItem('ip1', 'skeleton')
]
@@ -398,10 +398,7 @@ describe('OutputHistory', () => {
await nextTick()
await nextTick()
expect(lastEmission(result)).toMatchObject({
canShowPreview: true,
showSkeleton: true
})
expect(lastEmission(result)).toEqual({ canShowPreview: true })
})
it('emits latentPreviewUrl for in-progress latent item', async () => {
@@ -499,7 +496,7 @@ describe('OutputHistory', () => {
expect(lastEmission(result).canShowPreview).toBe(false)
})
it('emits skeleton for pending slot selection', async () => {
it('emits canShowPreview for pending slot selection', async () => {
mayBeActiveWorkflowPendingRef.value = true
runningTasksRef.value = [{ jobId: 'j1' }]
@@ -510,10 +507,7 @@ describe('OutputHistory', () => {
await nextTick()
await nextTick()
expect(lastEmission(result)).toMatchObject({
canShowPreview: true,
showSkeleton: true
})
expect(lastEmission(result)).toEqual({ canShowPreview: true })
})
})

View File

@@ -123,7 +123,7 @@ function doEmit() {
(i) => i.id === sel.itemId
)
if (!item || item.state === 'skeleton') {
emit('updateSelection', { canShowPreview: true, showSkeleton: true })
emit('updateSelection', { canShowPreview: true })
} else if (item.state === 'latent') {
emit('updateSelection', {
canShowPreview: true,
@@ -298,10 +298,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
})
</script>
<template>
<div
role="group"
class="flex h-21 min-w-0 items-start justify-center px-4 py-3 pb-4"
>
<div role="group" class="flex h-21 min-w-0 items-start px-4 py-3 pb-4">
<div
v-if="queueCount > 0 || hasActiveContent"
class="flex h-15 shrink-0 items-start gap-0.5"
@@ -347,7 +344,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
<article
ref="outputsRef"
data-testid="linear-outputs"
class="min-w-0 overflow-x-auto overflow-y-clip"
class="min-w-0 flex-1 overflow-x-auto overflow-y-clip contain-[inline-size]"
>
<div class="flex h-15 w-fit items-start gap-0.5">
<template v-for="(asset, aIdx) in visibleHistory" :key="asset.id">

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { computeFanLayout } from '@/renderer/extensions/linearMode/fanLayout'
describe('computeFanLayout', () => {
it('places the newest card (depth 0) dead centre, frontmost and largest', () => {
const newest = computeFanLayout(0, 3)
expect(newest.x).toBe(0)
expect(newest.rotate).toBe(0)
expect(newest.scale).toBe(1)
expect(newest.opacity).toBe(1)
// Highest z of the fan so it paints on top.
expect(newest.z).toBe(3)
})
it('fans older cards out to alternating sides, scaled and faded back', () => {
const cards = [0, 1, 2].map((depth) => computeFanLayout(depth, 3))
const [newest, mid, oldest] = cards
// Older cards sit off-centre on alternating sides.
expect(newest.x).toBe(0)
expect(mid.x).toBeGreaterThan(0)
expect(oldest.x).toBeLessThan(0)
expect(Math.sign(mid.x)).not.toBe(Math.sign(oldest.x))
// Depth recedes: smaller, more transparent, lower z.
expect(mid.scale).toBeLessThan(newest.scale)
expect(oldest.scale).toBeLessThan(mid.scale)
expect(mid.opacity).toBeLessThan(newest.opacity)
expect(oldest.z).toBeLessThan(mid.z)
})
it('clamps scale and opacity so deep cards never vanish', () => {
const deep = computeFanLayout(20, 21)
expect(deep.scale).toBe(0.7)
expect(deep.opacity).toBe(0.3)
})
})

View File

@@ -0,0 +1,38 @@
interface FanLayout {
x: number
y: number
rotate: number
scale: number
opacity: number
z: number
}
const CARD_GAP_PX = 30
const CARD_LIFT_PX = 8
const CARD_TILT_DEG = 5
const DEPTH_SCALE_STEP = 0.06
const DEPTH_OPACITY_STEP = 0.14
const MIN_SCALE = 0.7
const MIN_OPACITY = 0.3
/**
* Lays a card out in the generating-screen fan. Cards spread from the centre by
* age: the oldest sit at the outer edges (alternating left/right) and the newest
* lands dead centre, frontmost and largest.
*
* @param depth recency rank, 0 = newest (frontmost)
* @param total number of cards in the fan
*/
export function computeFanLayout(depth: number, total: number): FanLayout {
const age = total - 1 - depth // 0 = oldest
const slot = age % 2 === 0 ? age / 2 : total - 1 - (age - 1) / 2
const fromCenter = slot - (total - 1) / 2
return {
x: fromCenter * CARD_GAP_PX,
y: Math.abs(fromCenter) * CARD_LIFT_PX,
rotate: fromCenter * CARD_TILT_DEG,
scale: Math.max(MIN_SCALE, 1 - depth * DEPTH_SCALE_STEP),
opacity: Math.max(MIN_OPACITY, 1 - depth * DEPTH_OPACITY_STEP),
z: total - depth
}
}

View File

@@ -4,6 +4,8 @@ import type { ResultItemImpl } from '@/stores/queueStore'
export interface InProgressItem {
id: string
jobId: string
/** Monotonic arrival order, assigned at creation. Used to order the fan. */
seq: number
state: 'skeleton' | 'latent' | 'image'
latentPreviewUrl?: string
output?: ResultItemImpl
@@ -14,7 +16,6 @@ export interface OutputSelection {
output?: ResultItemImpl
canShowPreview: boolean
latentPreviewUrl?: string
showSkeleton?: boolean
}
export type SelectionValue =

View File

@@ -2,7 +2,10 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
import {
GENERATING_CARD_LIMIT,
useLinearOutputStore
} from '@/renderer/extensions/linearMode/linearOutputStore'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { ResultItemImpl } from '@/stores/queueStore'
@@ -803,6 +806,125 @@ describe('linearOutputStore', () => {
expect(imageItems[0].output?.nodeId).toBe('2')
})
it('pops non-selected outputs into the generating fan without feeding history', () => {
selectedOutputsRef.value = ['2']
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
// Node 1 is not a selected output — it must not enter the feed...
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
expect(
store.inProgressItems.filter((i) => i.state === 'image')
).toHaveLength(0)
expect(
store.activeWorkflowInProgressItems.filter((i) => i.state === 'image')
).toHaveLength(0)
// ...but it still pops as a card in the generating screen.
expect(
store.generatingCards.filter(
(c) => c.state === 'image' && c.output?.nodeId === '1'
)
).toHaveLength(1)
})
it('orders generating cards by arrival, non-selected above earlier selected', () => {
selectedOutputsRef.value = ['2']
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
// Selected output completes first (fills the skeleton slot)...
store.onNodeExecuted(
'job-1',
makeExecutedDetail(
'job-1',
[{ filename: 'selected.png', subfolder: '', type: 'output' }],
'2'
)
)
// ...then a non-selected output arrives later.
store.onNodeExecuted(
'job-1',
makeExecutedDetail(
'job-1',
[{ filename: 'extra.png', subfolder: '', type: 'output' }],
'1'
)
)
// The later non-selected card is newest, so it leads the fan.
expect(store.generatingCards[0].output?.filename).toBe('extra.png')
expect(store.generatingCards[1].output?.filename).toBe('selected.png')
})
it('clears non-selected generating cards when the run ends', async () => {
const { nextTick } = await import('vue')
selectedOutputsRef.value = ['2']
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
activeJobIdRef.value = 'job-1'
await nextTick()
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
expect(store.generatingCards.some((c) => c.output?.nodeId === '1')).toBe(
true
)
activeJobIdRef.value = null
await nextTick()
expect(store.generatingCards.some((c) => c.output?.nodeId === '1')).toBe(
false
)
})
it('caps the generating fan at GENERATING_CARD_LIMIT, keeping the newest', () => {
selectedOutputsRef.value = ['1']
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
store.onJobStart('job-1')
const outputs = Array.from(
{ length: GENERATING_CARD_LIMIT + 2 },
(_, i) => ({
filename: `out-${i}.png`,
subfolder: '',
type: 'output'
})
)
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', outputs, '1'))
const fan = store.generatingCards
expect(fan).toHaveLength(GENERATING_CARD_LIMIT)
// The newest GENERATING_CARD_LIMIT outputs survive; the earliest drop off.
expect(fan.map((c) => c.output?.filename)).toEqual([
'out-4.png',
'out-3.png',
'out-2.png'
])
})
it('clears stale non-selected cards from a prior run on the next job start', () => {
selectedOutputsRef.value = ['2']
const store = useLinearOutputStore()
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
expect(store.generatingCards.some((c) => c.output?.nodeId === '1')).toBe(
true
)
store.onJobStart('job-2')
expect(store.generatingCards.some((c) => c.output?.nodeId === '1')).toBe(
false
)
})
it('does not auto-select for jobs belonging to another workflow', () => {
const store = useLinearOutputStore()

View File

@@ -12,6 +12,9 @@ import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
// Max cards shown in the generating screen's fan before the oldest drop off.
export const GENERATING_CARD_LIMIT = 3
export const useLinearOutputStore = defineStore('linearOutput', () => {
const { isAppMode } = useAppMode()
const appModeStore = useAppModeStore()
@@ -20,6 +23,9 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
const workflowStore = useWorkflowStore()
const inProgressItems = ref<InProgressItem[]>([])
// Outputs from nodes not selected in the builder: shown in the generating
// screen so every result still "pops", but never added to the output feed.
const generatingExtraCards = ref<InProgressItem[]>([])
const resolvedOutputsCache = new Map<string, ResultItemImpl[]>()
const selectedId = ref<string | null>(null)
const isFollowing = ref(true)
@@ -36,10 +42,23 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
)
})
// Cards for the generating screen's fan: selected (feed) and non-selected
// outputs interleaved by true arrival order so the newest is always first,
// regardless of which list it came from.
const generatingCards = computed<InProgressItem[]>(() =>
[...activeWorkflowInProgressItems.value, ...generatingExtraCards.value]
.sort((a, b) => b.seq - a.seq)
.slice(0, GENERATING_CARD_LIMIT)
)
let nextSeq = 0
function makeItemId(jobId: JobId): string {
return `job-${jobId}-${nextSeq++}`
function createItem(
jobId: JobId,
props: Omit<InProgressItem, 'id' | 'jobId' | 'seq'>
): InProgressItem {
const seq = nextSeq++
return { id: `job-${jobId}-${seq}`, jobId, seq, ...props }
}
function replaceItem(
@@ -57,12 +76,11 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
function onJobStart(jobId: JobId) {
executedNodeIds.clear()
// Drop any non-selected cards left over from a previous run so back-to-back
// jobs don't carry stale cards into the new fan.
generatingExtraCards.value = []
const item: InProgressItem = {
id: makeItemId(jobId),
jobId,
state: 'skeleton'
}
const item = createItem(jobId, { state: 'skeleton' })
currentSkeletonId.value = item.id
inProgressItems.value = [item, ...inProgressItems.value]
@@ -95,12 +113,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
// Only create on-demand for the tracked job
if (jobId !== trackedJobId.value) return
const item: InProgressItem = {
id: makeItemId(jobId),
jobId,
state: 'latent',
latentPreviewUrl: url
}
const item = createItem(jobId, { state: 'latent', latentPreviewUrl: url })
currentSkeletonId.value = item.id
inProgressItems.value = [item, ...inProgressItems.value]
autoSelect(`slot:${item.id}`, jobId)
@@ -117,13 +130,26 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
const newOutputs = flattenNodeOutput([nodeId, detail.output])
if (newOutputs.length === 0) return
// Skip output items for nodes not flagged as output nodes
// Outputs from nodes not selected in the builder stay out of the feed, but
// still pop into the generating screen so the run feels alive.
const outputNodeIds = appModeStore.selectedOutputs
if (
outputNodeIds.length > 0 &&
!outputNodeIds.some((id) => String(id) === String(nodeId))
)
const isSelectedOutput =
outputNodeIds.length === 0 ||
outputNodeIds.some((id) => String(id) === String(nodeId))
if (!isSelectedOutput) {
if (jobId === trackedJobId.value) {
const extras = newOutputs.map((o) =>
createItem(jobId, { state: 'image', output: o })
)
// Only the newest GENERATING_CARD_LIMIT can ever surface in the fan, so
// cap on insert rather than retaining every non-selected output.
generatingExtraCards.value = [
...extras,
...generatingExtraCards.value
].slice(0, GENERATING_CARD_LIMIT)
}
return
}
const skeletonItem = inProgressItems.value.find(
(i) => i.id === currentSkeletonId.value && i.jobId === jobId
@@ -138,12 +164,9 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
}
autoSelect(`slot:${imageItem.id}`, jobId)
const extras: InProgressItem[] = newOutputs.slice(1).map((o) => ({
id: makeItemId(jobId),
jobId,
state: 'image' as const,
output: o
}))
const extras = newOutputs
.slice(1)
.map((o) => createItem(jobId, { state: 'image', output: o }))
const idx = inProgressItems.value.indexOf(skeletonItem)
const arr = [...inProgressItems.value]
@@ -156,12 +179,9 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
// No skeleton — create image items directly (only for tracked job)
if (jobId !== trackedJobId.value) return
const newItems: InProgressItem[] = newOutputs.map((o) => ({
id: makeItemId(jobId),
jobId,
state: 'image' as const,
output: o
}))
const newItems = newOutputs.map((o) =>
createItem(jobId, { state: 'image', output: o })
)
autoSelect(`slot:${newItems[0].id}`, jobId)
inProgressItems.value = [...newItems, ...inProgressItems.value]
}
@@ -285,6 +305,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
) {
onJobStart(jobId)
}
if (!jobId) generatingExtraCards.value = []
}
)
@@ -301,6 +322,8 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
)
function reconcileOnEnter() {
// Drop stale non-selected cards from a run that ended while away.
if (!executionStore.activeJobId) generatingExtraCards.value = []
// Complete any tracked job that finished while we were away.
// The activeJobId watcher couldn't fire onJobComplete because
// isAppMode was false at the time.
@@ -367,6 +390,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
return {
activeWorkflowInProgressItems,
generatingCards,
resolvedOutputsCache,
selectedId,
pendingResolve,

View File

@@ -297,12 +297,14 @@ describe(useOutputHistory, () => {
{
id: 'item-1',
jobId: 'job-1',
seq: 0,
state: 'image',
output: makeResult('a.png')
},
{
id: 'item-2',
jobId: 'job-1',
seq: 1,
state: 'image',
output: makeResult('b.png')
}
@@ -422,7 +424,7 @@ describe(useOutputHistory, () => {
it('returns false when there are active in-progress items', () => {
activeWorkflowInProgressItemsRef.value = [
{ id: 'item-1', jobId: 'job-1', state: 'skeleton' }
{ id: 'item-1', jobId: 'job-1', seq: 0, state: 'skeleton' }
]
runningTasksRef.value = [{ jobId: 'job-1' }]
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])

View File

@@ -821,6 +821,74 @@ describe('appModeStore', () => {
})
})
describe('displayViewMode', () => {
const rafQueue = new Map<number, FrameRequestCallback>()
let nextHandle: number
const advanceFrame = () => {
const callbacks = [...rafQueue.values()]
rafQueue.clear()
for (const cb of callbacks) cb(0)
}
beforeEach(() => {
rafQueue.clear()
nextHandle = 0
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
const handle = ++nextHandle
rafQueue.set(handle, cb)
return handle
})
vi.stubGlobal('cancelAnimationFrame', (handle: number) => {
rafQueue.delete(handle)
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('lags the view mode by two frames so the toggle can animate the switch', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
await nextTick()
expect(store.displayViewMode).toBe('graph')
workflowStore.activeWorkflow.activeMode = 'app'
await nextTick()
// The real mode has flipped, but the displayed mode still lags so a toggle
// that mounts now renders the old order before animating to the new one.
expect(store.viewMode).toBe('app')
expect(store.displayViewMode).toBe('graph')
// First frame only schedules the second; the displayed mode must not move.
advanceFrame()
expect(store.displayViewMode).toBe('graph')
// The second frame is the one that flips the displayed mode.
advanceFrame()
expect(store.displayViewMode).toBe('app')
})
it('cancels a stale frame chain so a rapid toggle has no transient flash', async () => {
workflowStore.activeWorkflow = createBuilderWorkflow('graph')
workflowStore.activeWorkflow.activeMode = 'app'
await nextTick()
advanceFrame()
workflowStore.activeWorkflow.activeMode = 'graph'
await nextTick()
// The pending second frame from the first toggle is cancelled, so it can
// no longer flip the displayed mode to 'app' before settling on 'graph'.
advanceFrame()
expect(store.displayViewMode).toBe('graph')
advanceFrame()
expect(store.displayViewMode).toBe('graph')
})
})
describe('legacy selectedInput tuple migration', () => {
const rootGraphId = '11111111-1111-4111-8111-111111111111'

View File

@@ -28,6 +28,7 @@ import { parseNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type { WidgetId } from '@/types/widgetId'
import { isWidgetId, parseWidgetId } from '@/types/widgetId'
import type { ViewMode } from '@/utils/appMode'
function findWidgetByEntityId(
rootGraph: LGraph,
@@ -50,11 +51,33 @@ export const useAppModeStore = defineStore('appMode', () => {
const { getCanvas } = useCanvasStore()
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const { mode, setMode, isBuilderMode, isSelectMode } = useAppMode()
const { mode, setMode, isAppMode, isBuilderMode, isSelectMode } = useAppMode()
const emptyWorkflowDialog = useEmptyWorkflowDialog()
const showVueNodeSwitchPopup = ref(false)
const viewMode = computed<ViewMode>(() => (isAppMode.value ? 'app' : 'graph'))
/**
* Frame-lagged mirror of {@link viewMode} that drives the view-mode toggle's
* segment morph. Lagging by two frames lets a toggle that mounts mid-switch
* render the previous mode first, then animate into the new one. It lives in
* the store so the value outlives the graph-mode toggle unmounting and the
* app-mode toggle mounting in its place during a switch.
*/
const displayViewMode = ref<ViewMode>(viewMode.value)
let outerFrame: number | undefined
let innerFrame: number | undefined
watch(viewMode, (next) => {
if (outerFrame !== undefined) cancelAnimationFrame(outerFrame)
if (innerFrame !== undefined) cancelAnimationFrame(innerFrame)
outerFrame = requestAnimationFrame(() => {
innerFrame = requestAnimationFrame(() => {
displayViewMode.value = next
})
})
})
const selectedInputs = ref<LinearInput[]>([])
const selectedOutputs = ref<NodeId[]>([])
const hasOutputs = computed(() => !!selectedOutputs.value.length)
@@ -292,6 +315,8 @@ export const useAppModeStore = defineStore('appMode', () => {
selectedInputs,
selectedOutputs,
updateInputConfig,
showVueNodeSwitchPopup
showVueNodeSwitchPopup,
viewMode,
displayViewMode
}
})

View File

@@ -5,6 +5,8 @@ export type AppMode =
| 'builder:outputs'
| 'builder:arrange'
export type ViewMode = 'graph' | 'app'
type WorkflowModeSource = {
activeMode: AppMode | null
initialMode: AppMode | null | undefined

View File

@@ -0,0 +1,232 @@
import { render, screen } from '@testing-library/vue'
import type { DetachedWindowAPI } from 'happy-dom'
import { defineComponent } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SidebarTabExtension } from '@/types/extensionTypes'
import LinearView from './LinearView.vue'
interface ViewState {
sidebarLocation: 'left' | 'right'
isBuilderMode: boolean
isArrangeMode: boolean
activeTab: SidebarTabExtension | null
hasOutputs: boolean
}
const state = vi.hoisted<ViewState>(() => ({
sidebarLocation: 'left',
isBuilderMode: false,
isArrangeMode: false,
activeTab: null,
hasOutputs: false
}))
const onResizeEnd = vi.hoisted(() => vi.fn())
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
key === 'Comfy.Sidebar.Location' ? state.sidebarLocation : undefined
})
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({
sidebarTab: {
get activeSidebarTab() {
return state.activeTab
}
}
})
}))
vi.mock('@/composables/useAppMode', async () => {
const { computed } = await import('vue')
return {
useAppMode: () => ({
isBuilderMode: computed(() => state.isBuilderMode),
isArrangeMode: computed(() => state.isArrangeMode)
})
}
})
vi.mock('@/stores/appModeStore', async () => {
const { reactive, computed } = await import('vue')
return {
useAppModeStore: () =>
reactive({ hasOutputs: computed(() => state.hasOutputs) })
}
})
vi.mock('@/composables/useStablePrimeVueSplitterSizer', () => ({
useStablePrimeVueSplitterSizer: () => ({ onResizeEnd })
}))
function setViewport(width: number) {
const happyDOM = (window as unknown as { happyDOM?: DetachedWindowAPI })
.happyDOM
if (!happyDOM) {
throw new Error('window.happyDOM is unavailable to set viewport')
}
happyDOM.setViewport({ width, height: 800 })
}
const DESKTOP_WIDTH = 1280
const MOBILE_WIDTH = 640
const passthroughStub = { template: '<div><slot /></div>' }
function leafStub(testId: string) {
return { template: `<div data-testid="${testId}" />` }
}
const baseStubs = {
Splitter: passthroughStub,
SplitterPanel: passthroughStub,
MobileDisplay: leafStub('mobile-display'),
AppBuilder: leafStub('app-builder'),
AppModeToolbar: leafStub('app-mode-toolbar'),
ExtensionSlot: leafStub('extension-slot'),
SideToolbar: leafStub('side-toolbar'),
TopbarBadges: leafStub('topbar-badges'),
TopbarSubscribeButton: leafStub('topbar-subscribe-button'),
WorkflowTabs: leafStub('workflow-tabs'),
LinearControls: leafStub('linear-controls'),
LinearPreview: leafStub('linear-preview'),
LinearProgressBar: leafStub('linear-progress-bar')
}
function renderView(overrides: Partial<ViewState> = {}) {
Object.assign(state, overrides)
return render(LinearView, {
global: { stubs: baseStubs }
})
}
const sampleTab: SidebarTabExtension = {
id: 'assets',
title: 'Assets',
type: 'custom',
render: () => {}
}
function expectRenderedBefore(first: HTMLElement, second: HTMLElement) {
expect(
first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
}
describe('LinearView', () => {
beforeEach(() => {
vi.clearAllMocks()
setViewport(DESKTOP_WIDTH)
Object.assign(state, {
sidebarLocation: 'left',
isBuilderMode: false,
isArrangeMode: false,
activeTab: null,
hasOutputs: false
} satisfies ViewState)
})
it('renders only the mobile display on small screens', () => {
setViewport(MOBILE_WIDTH)
renderView()
expect(screen.getByTestId('mobile-display')).toBeInTheDocument()
expect(screen.queryByTestId('workflow-tabs')).not.toBeInTheDocument()
expect(screen.queryByTestId('linear-preview')).not.toBeInTheDocument()
})
it('renders the desktop layout with the center panel on larger screens', () => {
renderView()
expect(screen.queryByTestId('mobile-display')).not.toBeInTheDocument()
expect(screen.getByTestId('workflow-tabs')).toBeInTheDocument()
expect(screen.getByTestId('linear-header-progress-bar')).toBeInTheDocument()
expect(screen.getByTestId('linear-preview')).toBeInTheDocument()
})
it('shows the toolbar and puts the active tab before the controls for a left sidebar', () => {
renderView({
sidebarLocation: 'left',
activeTab: sampleTab,
hasOutputs: true
})
expect(screen.getByTestId('side-toolbar')).toBeInTheDocument()
expect(screen.getByTestId('app-mode-toolbar')).toBeInTheDocument()
expectRenderedBefore(
screen.getByTestId('extension-slot'),
screen.getByTestId('linear-controls')
)
})
it('puts the controls before the active tab when the sidebar is on the right', () => {
renderView({
sidebarLocation: 'right',
activeTab: sampleTab,
hasOutputs: true
})
expectRenderedBefore(
screen.getByTestId('linear-controls'),
screen.getByTestId('extension-slot')
)
})
it('omits both side panels when there is no active tab or output', () => {
renderView({ activeTab: null, hasOutputs: false })
expect(screen.queryByTestId('extension-slot')).not.toBeInTheDocument()
expect(screen.queryByTestId('linear-controls')).not.toBeInTheDocument()
expect(screen.getByTestId('linear-preview')).toBeInTheDocument()
})
it('shows the app builder in the right panel for left sidebar arrange mode', () => {
renderView({
sidebarLocation: 'left',
isBuilderMode: true,
isArrangeMode: true
})
expect(screen.getByTestId('app-builder')).toBeInTheDocument()
expect(screen.queryByTestId('side-toolbar')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-mode-toolbar')).not.toBeInTheDocument()
})
it('shows the app builder in the left panel for right sidebar arrange mode', () => {
renderView({
sidebarLocation: 'right',
isBuilderMode: true,
isArrangeMode: true
})
expect(screen.getByTestId('app-builder')).toBeInTheDocument()
expect(screen.queryByTestId('side-toolbar')).not.toBeInTheDocument()
})
it('blocks the native splitter resize start and runs the resize-end handler', () => {
const preventDefault = vi.fn()
render(LinearView, {
global: {
stubs: {
...baseStubs,
Splitter: defineComponent({
emits: ['resizestart', 'resizeend'],
mounted() {
this.$emit('resizestart', { originalEvent: { preventDefault } })
this.$emit('resizeend')
},
template: '<div><slot /></div>'
})
}
}
})
expect(preventDefault).toHaveBeenCalled()
expect(onResizeEnd).toHaveBeenCalled()
})
})

View File

@@ -9,11 +9,11 @@ import { computed, useTemplateRef } from 'vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { cn } from '@comfyorg/tailwind-utils'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import LinearPreview from '@/renderer/extensions/linearMode/LinearPreview.vue'
import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBar.vue'
@@ -85,8 +85,6 @@ const { onResizeEnd } = useStablePrimeVueSplitterSizer(
[activeTab, splitterKey]
)
const TYPEFORM_WIDGET_ID = 'jmmzmlKw'
const bottomLeftRef = useTemplateRef('bottomLeftRef')
const bottomRightRef = useTemplateRef('bottomRightRef')
const linearWorkflowRef = useTemplateRef('linearWorkflowRef')
@@ -98,7 +96,7 @@ function dragDrop(e: DragEvent) {
</script>
<template>
<MobileDisplay v-if="mobileDisplay" />
<div v-else class="absolute size-full" @dragover.prevent>
<div v-else class="absolute flex size-full flex-col" @dragover.prevent>
<div
class="workflow-tabs-container pointer-events-auto h-(--workflow-tabs-height) w-full border-b border-interface-stroke shadow-interface"
>
@@ -108,92 +106,94 @@ function dragDrop(e: DragEvent) {
<TopbarSubscribeButton />
</div>
</div>
<Splitter
:key="splitterKey"
class="bg-comfy-menu-secondary-bg h-[calc(100%-var(--workflow-tabs-height))] w-full border-none"
@resizestart="$event.originalEvent.preventDefault()"
@resizeend="onResizeEnd"
<div
class="flex flex-1 overflow-hidden bg-secondary-background"
:class="sidebarOnLeft ? 'flex-row' : 'flex-row-reverse'"
>
<SplitterPanel
v-if="hasLeftPanel"
ref="leftPanel"
:size="SIDE_PANEL_SIZE"
:min-size="
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
"
:style="
showRightBuilder && !activeTab ? { display: 'none' } : undefined
"
:class="
cn(
'arrange-panel overflow-hidden outline-none',
showLeftBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
)
"
<SideToolbar
v-if="!isBuilderMode"
:visible-tab-ids="['assets', 'apps']"
force-connected
hide-workspace-toggles
/>
<Splitter
:key="splitterKey"
class="h-full flex-1 border-none bg-secondary-background"
@resizestart="$event.originalEvent.preventDefault()"
@resizeend="onResizeEnd"
>
<AppBuilder v-if="showLeftBuilder" />
<div
v-else-if="sidebarOnLeft && activeTab"
class="size-full overflow-x-hidden border-r border-border-subtle"
<SplitterPanel
v-if="hasLeftPanel"
ref="leftPanel"
:size="SIDE_PANEL_SIZE"
:min-size="
sidePanelMinSize(showLeftBuilder, showRightBuilder && !activeTab)
"
:style="
showRightBuilder && !activeTab ? { display: 'none' } : undefined
"
class="arrange-panel min-w-78 overflow-hidden bg-comfy-menu-bg outline-none"
>
<ExtensionSlot :extension="activeTab" />
</div>
<LinearControls
v-else-if="!isArrangeMode"
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
/>
</SplitterPanel>
<SplitterPanel
id="linearCenterPanel"
data-testid="linear-center-panel"
:size="CENTER_PANEL_SIZE"
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
@drop="dragDrop"
>
<LinearProgressBar
data-testid="linear-header-progress-bar"
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
/>
<LinearPreview
:run-button-click="linearWorkflowRef?.runButtonClick"
:typeform-widget-id="TYPEFORM_WIDGET_ID"
/>
<div class="absolute top-2 left-4.5 z-21">
<AppModeToolbar v-if="!isBuilderMode" />
</div>
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"
ref="rightPanel"
:size="SIDE_PANEL_SIZE"
:min-size="
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
"
:style="showLeftBuilder && !activeTab ? { display: 'none' } : undefined"
:class="
cn(
'arrange-panel overflow-hidden outline-none',
showRightBuilder ? 'min-w-78 bg-comfy-menu-bg' : 'min-w-78'
)
"
>
<AppBuilder v-if="showRightBuilder" />
<LinearControls
v-else-if="sidebarOnLeft && !isArrangeMode"
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomRightRef) ?? undefined"
/>
<div
v-else-if="activeTab"
class="h-full overflow-x-hidden border-l border-border-subtle"
<AppBuilder v-if="showLeftBuilder" />
<div
v-else-if="sidebarOnLeft && activeTab"
class="size-full overflow-x-hidden border-r border-border-subtle"
>
<ExtensionSlot :extension="activeTab" />
</div>
<LinearControls
v-else-if="!isArrangeMode"
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomLeftRef) ?? undefined"
/>
</SplitterPanel>
<SplitterPanel
id="linearCenterPanel"
data-testid="linear-center-panel"
:size="CENTER_PANEL_SIZE"
class="relative flex min-w-[20vw] flex-col gap-4 text-muted-foreground outline-none"
@drop="dragDrop"
>
<ExtensionSlot :extension="activeTab" />
</div>
</SplitterPanel>
</Splitter>
<LinearProgressBar
data-testid="linear-header-progress-bar"
class="absolute top-0 left-0 z-21 h-1 w-[calc(100%+16px)]"
/>
<LinearPreview
:run-button-click="linearWorkflowRef?.runButtonClick"
/>
<div class="absolute top-2 left-2 z-21">
<AppModeToolbar v-if="!isBuilderMode" />
</div>
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"
ref="rightPanel"
:size="SIDE_PANEL_SIZE"
:min-size="
sidePanelMinSize(showRightBuilder, showLeftBuilder && !activeTab)
"
:style="
showLeftBuilder && !activeTab ? { display: 'none' } : undefined
"
class="arrange-panel min-w-78 overflow-hidden bg-comfy-menu-bg outline-none"
>
<AppBuilder v-if="showRightBuilder" />
<LinearControls
v-else-if="sidebarOnLeft && !isArrangeMode"
ref="linearWorkflowRef"
:toast-to="unrefElement(bottomRightRef) ?? undefined"
/>
<div
v-else-if="activeTab"
class="h-full overflow-x-hidden border-l border-border-subtle"
>
<ExtensionSlot :extension="activeTab" />
</div>
</SplitterPanel>
</Splitter>
</div>
</div>
</template>