Compare commits

...

9 Commits

Author SHA1 Message Date
pythongosssss
e0300c62a1 chore: revert hand-edited non-English locale files
- Restore the 12 non-en main.json files; only en/main.json should be
  edited by hand, CI regenerates the rest from it
2026-07-07 07:37:41 -07:00
pythongosssss
5344f0f1f5 remove gradient background, add dark canvas color
restructure empty state panels + add border for contrast
2026-07-06 14:10:24 -07:00
pythongosssss
ac713c2699 refactor: address app mode landing review feedback
- Extract pure template helpers from useTemplateWorkflows into templateUtil
- Disable get-started actions while a template loads, toast on failure
- Open workflow import via Comfy.OpenWorkflow command
- Collapse duplicated welcome card markup into a single shell
- Replace off-scale arbitrary Tailwind values with scale utilities
- Test ready-to-run state, no-app-template fallback, load guard, and helpers
- Add e2e covering featured template load
2026-07-02 13:05:58 -07:00
pythongosssss
9dde0c193d feat: polish App mode landing states
- Style built-app message as a card ("Your app is ready to run", play icon)
- Solid bg on build-prompt card; radial-gradient bg on #linearCenterPanel
- Splitter bg -> base-background (theme-aware)
- Refresh welcome i18n copy; prune stale welcome keys across locales
2026-07-02 09:45:27 -07:00
pythongosssss
5da6a0389e update bg 2026-07-02 07:55:40 -07:00
pythongosssss
6dc61c6ddb remove 2026-07-02 07:50:15 -07:00
pythongosssss
44d066d134 remove conflicting bgs 2026-07-02 07:35:16 -07:00
pythongosssss
2c638d67c3 remove bg color change 2026-07-02 06:40:07 -07:00
pythongosssss
1c1c257f92 feat: redesign App mode empty-graph landing
- Empty graph: new "Get started with Apps" page (templates, import, discover)
- Populated: "Make this workflow an App" build-prompt card
- Share template source/app/thumbnail helpers via useTemplateWorkflows
- Drop unused welcome i18n keys and back-to-workflow button
- Update e2e selectors/specs; add unit tests
2026-07-01 11:25:19 -07:00
16 changed files with 701 additions and 343 deletions

View File

@@ -42,16 +42,16 @@ export class AppModeHelper {
public readonly imagePickerPopover: Locator
/** The Run button in the app mode footer. */
public readonly runButton: Locator
/** The welcome screen shown when app mode has no outputs or no nodes. */
/** The welcome card shown when the graph has nodes or outputs (build prompt / ready to run). */
public readonly welcome: Locator
/** The empty workflow message shown when no nodes exist. */
public readonly emptyWorkflowText: Locator
/** The "Build app" button shown when nodes exist but no outputs. */
public readonly buildAppButton: Locator
/** The "Back to workflow" button on the welcome screen. */
public readonly backToWorkflowButton: Locator
/** The "Load template" button shown when no nodes exist. */
public readonly loadTemplateButton: Locator
/** The get started page shown when the graph is empty. */
public readonly getStarted: Locator
/** The "Discover all templates" button on the get started page. */
public readonly getStartedDiscoverButton: Locator
/** Featured template cards on the get started page. */
public readonly getStartedTemplateCards: Locator
/** The cancel button for an in-progress run in the output history. */
public readonly cancelRunButton: Locator
/** Arrange-step placeholder shown when outputs are configured but no run has happened. */
@@ -111,15 +111,13 @@ export class AppModeHelper {
.getByTestId(TestIds.linear.runButton)
.getByRole('button', { name: /run/i })
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
this.emptyWorkflowText = this.page.getByTestId(
TestIds.appMode.emptyWorkflow
)
this.buildAppButton = this.page.getByTestId(TestIds.appMode.buildApp)
this.backToWorkflowButton = this.page.getByTestId(
TestIds.appMode.backToWorkflow
this.getStarted = this.page.getByTestId(TestIds.appMode.getStarted)
this.getStartedDiscoverButton = this.page.getByTestId(
TestIds.appMode.getStartedDiscover
)
this.loadTemplateButton = this.page.getByTestId(
TestIds.appMode.loadTemplate
this.getStartedTemplateCards = this.page.getByTestId(
TestIds.appMode.getStartedTemplate
)
this.cancelRunButton = this.page.getByTestId(
TestIds.outputHistory.cancelRun

View File

@@ -218,10 +218,10 @@ export const TestIds = {
appMode: {
widgetItem: 'app-mode-widget-item',
welcome: 'linear-welcome',
emptyWorkflow: 'linear-welcome-empty-workflow',
buildApp: 'linear-welcome-build-app',
backToWorkflow: 'linear-welcome-back-to-workflow',
loadTemplate: 'linear-welcome-load-template',
getStarted: 'linear-get-started',
getStartedDiscover: 'linear-get-started-discover',
getStartedTemplate: 'linear-get-started-template',
arrangePreview: 'linear-arrange-preview',
arrangeNoOutputs: 'linear-arrange-no-outputs',
arrangeSwitchToOutputs: 'linear-arrange-switch-to-outputs',

View File

@@ -9,14 +9,12 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await comfyPage.appMode.suppressVueNodeSwitchPopup()
})
test('Empty workflow text is visible when no nodes', async ({
comfyPage
}) => {
test('Get started page is visible when no nodes', async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
await expect(comfyPage.appMode.getStarted).toBeVisible()
await expect(comfyPage.appMode.welcome).toBeHidden()
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
})
@@ -27,35 +25,41 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.buildAppButton).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
await expect(comfyPage.appMode.getStarted).toBeHidden()
})
test('Empty workflow and build app are hidden when app has outputs', async ({
test('Get started and build app are hidden when app has outputs', async ({
comfyPage
}) => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
await expect(comfyPage.appMode.getStarted).toBeHidden()
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
})
test('Back to workflow returns to graph mode', async ({ comfyPage }) => {
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await comfyPage.appMode.backToWorkflowButton.click()
await expect(comfyPage.canvas).toBeVisible()
await expect(comfyPage.appMode.welcome).toBeHidden()
})
test('Load template opens template selector', async ({ comfyPage }) => {
test('Clicking a featured template loads it into the graph', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await comfyPage.appMode.loadTemplateButton.click()
await comfyPage.appMode.getStartedTemplateCards.first().click()
await expect(comfyPage.appMode.getStarted).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
})
test('Discover all templates opens template selector', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.getStarted).toBeVisible()
await comfyPage.appMode.getStartedDiscoverButton.click()
await expect(comfyPage.templates.content).toBeVisible()
})

View File

@@ -11,6 +11,7 @@
--color-charcoal-600: #262729;
--color-charcoal-700: #202121;
--color-charcoal-800: #171718;
--color-charcoal-900: #141414;
--color-neutral-550: #636363;

View File

@@ -457,6 +457,7 @@
);
--color-interface-menu-surface: var(--interface-menu-surface);
--color-interface-menu-stroke: var(--interface-menu-stroke);
--color-interface-canvas-background: var(--color-charcoal-900);
--color-interface-panel-surface: var(--interface-panel-surface);
--color-interface-panel-hover-surface: var(--interface-panel-hover-surface);
--color-interface-panel-selected-surface: var(

View File

@@ -430,6 +430,14 @@ import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import {
getBaseThumbnailSrc,
getEffectiveSourceModule,
getOverlayThumbnailSrc,
getTemplateDescription,
getTemplateTitle,
isAppTemplate
} from '@/platform/workflow/templates/utils/templateUtil'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
@@ -468,28 +476,7 @@ provide(OnCloseKey, onClose)
// Workflow templates store and composable
const workflowTemplatesStore = useWorkflowTemplatesStore()
const {
loadTemplates,
loadWorkflowTemplate,
getTemplateThumbnailUrl,
getTemplateTitle,
getTemplateDescription
} = useTemplateWorkflows()
const getEffectiveSourceModule = (template: TemplateInfo) =>
template.sourceModule || 'default'
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
const getBaseThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
}
const getOverlayThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '2' : '')
}
const { loadTemplates, loadWorkflowTemplate } = useTemplateWorkflows()
// Open tutorial in new tab
const openTutorial = (template: TemplateInfo) => {

View File

@@ -3711,14 +3711,21 @@
"viewGraph": "View node graph",
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",
"controls": "Your outputs appear at the bottom, your controls are on the right. Everything else stays out of the way.",
"sharing": "Share your workflow as a simple tool anyone can use. Export it from the tab menu and when others open it, they'll see App Mode. No node graph knowledge needed.",
"getStarted": "Click {runButton} to get started.",
"buildApp": "Build app",
"noOutputs": "An app needs at least {count} to be usable.",
"oneOutput": "1 output"
"title": "Your app is ready to run",
"description": "Set your inputs then run to get started"
},
"getStarted": {
"title": "Get started with Apps",
"subtitle": "Pick an app template to get started. Each one is built on a workflow.",
"templates": "Templates",
"importWorkflow": "Import workflow",
"discoverAll": "Discover all templates",
"loadFailed": "Couldn't load this template. Please try again."
},
"buildPrompt": {
"title": "Make this workflow an App",
"description": "Pick which nodes become inputs and outputs, and we'll generate a simple form anyone can run.",
"button": "Build your App"
},
"appModeToolbar": {
"appBuilder": "App builder",

View File

@@ -162,114 +162,6 @@ describe('useTemplateWorkflows', () => {
expect(selectedTemplate.value).toEqual(category)
})
it('should format template thumbnails correctly for default templates', () => {
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
const template = {
name: 'test-template',
mediaSubtype: 'jpg',
mediaType: 'image',
description: 'Test template'
}
const url = getTemplateThumbnailUrl(template, 'default', '1')
expect(url).toBe('mock-file-url/templates/test-template-1.jpg')
})
it('should format template thumbnails correctly for custom templates', () => {
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
const template = {
name: 'test-template',
mediaSubtype: 'jpg',
mediaType: 'image',
description: 'Test template'
}
const url = getTemplateThumbnailUrl(template, 'custom-module')
expect(url).toBe(
'mock-api-url/workflow_templates/custom-module/test-template.jpg'
)
})
it('should format template titles correctly', () => {
const { getTemplateTitle } = useTemplateWorkflows()
// Default template with localized title
const titleWithLocalized = getTemplateTitle(
{
name: 'test',
localizedTitle: 'Localized Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'default'
)
expect(titleWithLocalized).toBe('Localized Title')
// Default template without localized title
const titleWithFallback = getTemplateTitle(
{
name: 'test',
title: 'Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'default'
)
expect(titleWithFallback).toBe('Title')
// Custom template
const customTitle = getTemplateTitle(
{
name: 'test-template',
title: 'Custom Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'custom-module'
)
expect(customTitle).toBe('Custom Title')
// Fallback to name
const nameOnly = getTemplateTitle(
{
name: 'name-only',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'custom-module'
)
expect(nameOnly).toBe('name-only')
})
it('should format template descriptions correctly', () => {
const { getTemplateDescription } = useTemplateWorkflows()
// Default template with localized description
const descWithLocalized = getTemplateDescription({
name: 'test',
localizedDescription: 'Localized Description',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
})
expect(descWithLocalized).toBe('Localized Description')
// Custom template with description
const customDesc = getTemplateDescription({
name: 'test',
description: 'custom-template_description',
mediaType: 'image',
mediaSubtype: 'jpg'
})
expect(customDesc).toBe('custom template description')
})
it('should load a template from the "All" category', async () => {
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()

View File

@@ -5,7 +5,6 @@ import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type {
TemplateGroup,
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { api } from '@/scripts/api'
@@ -55,45 +54,6 @@ export function useTemplateWorkflows() {
return category !== null
}
/**
* Gets template thumbnail URL
*/
const getTemplateThumbnailUrl = (
template: TemplateInfo,
sourceModule: string,
index = '1'
) => {
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
}
/**
* Gets formatted template title
*/
const getTemplateTitle = (template: TemplateInfo, sourceModule: string) => {
const fallback =
template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? (template.localizedTitle ?? fallback)
: fallback
}
/**
* Gets formatted template description
*/
const getTemplateDescription = (template: TemplateInfo) => {
return (
(template.localizedDescription || template.description)
?.replace(/[-_]/g, ' ')
.trim() ?? ''
)
}
/**
* Loads a workflow template
*/
@@ -177,9 +137,6 @@ export function useTemplateWorkflows() {
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
getTemplateThumbnailUrl,
getTemplateTitle,
getTemplateDescription,
loadWorkflowTemplate
}
}

View File

@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import {
getBaseThumbnailSrc,
getEffectiveSourceModule,
getOverlayThumbnailSrc,
getTemplateDescription,
getTemplateTitle,
isAppTemplate
} from '@/platform/workflow/templates/utils/templateUtil'
vi.mock('@/scripts/api', () => ({
api: {
fileURL: (path: string) => `mock-file-url${path}`,
apiURL: (path: string) => `mock-api-url${path}`
}
}))
function makeTemplate(overrides: Partial<TemplateInfo> = {}): TemplateInfo {
return {
name: 'test-template',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test template',
...overrides
}
}
describe('getEffectiveSourceModule', () => {
it('returns the template source module when set', () => {
expect(
getEffectiveSourceModule(makeTemplate({ sourceModule: 'custom-module' }))
).toBe('custom-module')
})
it('defaults to the frontend-provided set when unset or empty', () => {
expect(getEffectiveSourceModule(makeTemplate())).toBe('default')
expect(getEffectiveSourceModule(makeTemplate({ sourceModule: '' }))).toBe(
'default'
)
})
})
describe('isAppTemplate', () => {
it('detects the .app name suffix', () => {
expect(isAppTemplate(makeTemplate({ name: 'flux.app' }))).toBe(true)
expect(isAppTemplate(makeTemplate({ name: 'flux' }))).toBe(false)
expect(isAppTemplate(makeTemplate({ name: 'app.flux' }))).toBe(false)
})
})
describe('thumbnail sources', () => {
it('appends -1/-2 index suffixes for default templates', () => {
const template = makeTemplate()
expect(getBaseThumbnailSrc(template)).toBe(
'mock-file-url/templates/test-template-1.jpg'
)
expect(getOverlayThumbnailSrc(template)).toBe(
'mock-file-url/templates/test-template-2.jpg'
)
})
it('uses the unsuffixed API path for custom module templates', () => {
const template = makeTemplate({ sourceModule: 'custom-module' })
const expected =
'mock-api-url/workflow_templates/custom-module/test-template.jpg'
expect(getBaseThumbnailSrc(template)).toBe(expected)
expect(getOverlayThumbnailSrc(template)).toBe(expected)
})
})
describe('getTemplateTitle', () => {
it('prefers the localized title for default templates', () => {
expect(
getTemplateTitle(
makeTemplate({ title: 'Title', localizedTitle: 'Localized Title' }),
'default'
)
).toBe('Localized Title')
})
it('falls back to title then name', () => {
expect(getTemplateTitle(makeTemplate({ title: 'Title' }), 'default')).toBe(
'Title'
)
expect(getTemplateTitle(makeTemplate(), 'custom-module')).toBe(
'test-template'
)
})
it('ignores the localized title for custom module templates', () => {
expect(
getTemplateTitle(
makeTemplate({ title: 'Title', localizedTitle: 'Localized Title' }),
'custom-module'
)
).toBe('Title')
})
})
describe('getTemplateDescription', () => {
it('prefers the localized description', () => {
expect(
getTemplateDescription(
makeTemplate({ localizedDescription: 'Localized Description' })
)
).toBe('Localized Description')
})
it('replaces dashes and underscores with spaces', () => {
expect(
getTemplateDescription(
makeTemplate({ description: 'custom-template_description' })
)
).toBe('custom template description')
})
})

View File

@@ -0,0 +1,78 @@
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { api } from '@/scripts/api'
/**
* Source module a template loads from, defaulting to the frontend-provided set.
*/
export function getEffectiveSourceModule(template: TemplateInfo): string {
return template.sourceModule || 'default'
}
/**
* Whether a template targets App mode (name suffixed with `.app`).
*/
export function isAppTemplate(template: TemplateInfo): boolean {
return template.name.endsWith('.app')
}
function getTemplateThumbnailUrl(
template: TemplateInfo,
sourceModule: string,
index = '1'
): string {
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
}
/**
* Primary thumbnail URL for a template.
*/
export function getBaseThumbnailSrc(template: TemplateInfo): string {
const sourceModule = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(
template,
sourceModule,
sourceModule === 'default' ? '1' : ''
)
}
/**
* Secondary/hover thumbnail URL for a template.
*/
export function getOverlayThumbnailSrc(template: TemplateInfo): string {
const sourceModule = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(
template,
sourceModule,
sourceModule === 'default' ? '2' : ''
)
}
/**
* Formatted template title, preferring the localized title for default templates.
*/
export function getTemplateTitle(
template: TemplateInfo,
sourceModule: string
): string {
const fallback = template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? (template.localizedTitle ?? fallback)
: fallback
}
/**
* Formatted template description, preferring the localized description.
*/
export function getTemplateDescription(template: TemplateInfo): string {
return (
(template.localizedDescription || template.description)
?.replace(/[-_]/g, ' ')
.trim() ?? ''
)
}

View File

@@ -0,0 +1,199 @@
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 { TemplateInfo } from '@/platform/workflow/templates/types/template'
import LinearGetStarted from './LinearGetStarted.vue'
const {
templatesState,
loadTemplates,
loadWorkflowTemplate,
showDialog,
executeCommand,
addToast
} = vi.hoisted(() => ({
templatesState: {
isTemplatesLoaded: true,
loadingTemplateId: null as string | null,
enhancedTemplates: [] as TemplateInfo[]
},
loadTemplates: vi.fn(),
loadWorkflowTemplate: vi.fn(),
showDialog: vi.fn(),
executeCommand: vi.fn(),
addToast: vi.fn()
}))
vi.mock(
'@/platform/workflow/templates/composables/useTemplateWorkflows',
async () => {
const { computed } = await import('vue')
return {
useTemplateWorkflows: () => ({
isTemplatesLoaded: computed(() => templatesState.isTemplatesLoaded),
loadingTemplateId: computed(() => templatesState.loadingTemplateId),
loadTemplates,
loadWorkflowTemplate
})
}
}
)
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => ({
get enhancedTemplates() {
return templatesState.enhancedTemplates
}
})
})
)
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
useWorkflowTemplateSelectorDialog: () => ({ show: showDialog })
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: executeCommand })
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: addToast })
}))
vi.mock('@/scripts/api', () => ({
api: {
fileURL: (path: string) => path,
apiURL: (path: string) => path
}
}))
function makeTemplate(name: string, sourceModule?: string): TemplateInfo {
return {
name,
mediaType: 'image',
mediaSubtype: 'webp',
description: '',
...(sourceModule && { sourceModule })
}
}
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
function renderComponent() {
return render(LinearGetStarted, {
global: {
plugins: [i18n],
stubs: {
LazyImage: { template: '<div />' }
}
}
})
}
describe('LinearGetStarted', () => {
beforeEach(() => {
vi.clearAllMocks()
templatesState.isTemplatesLoaded = true
templatesState.loadingTemplateId = null
templatesState.enhancedTemplates = [
makeTemplate('a.app'),
makeTemplate('b.app', 'mymod'),
makeTemplate('c'),
makeTemplate('d.app'),
makeTemplate('e.app'),
makeTemplate('f.app')
]
loadWorkflowTemplate.mockResolvedValue(true)
})
it('loads templates on mount', () => {
renderComponent()
expect(loadTemplates).toHaveBeenCalled()
})
it('shows only the first four app templates', () => {
renderComponent()
const cards = screen.getAllByTestId('linear-get-started-template')
expect(cards).toHaveLength(4)
expect(screen.getByText('a.app')).toBeInTheDocument()
expect(screen.getByText('e.app')).toBeInTheDocument()
expect(screen.queryByText('f.app')).not.toBeInTheDocument()
expect(screen.queryByText('c')).not.toBeInTheDocument()
})
it('falls back to the first four templates when none target app mode', () => {
templatesState.enhancedTemplates = [
makeTemplate('one'),
makeTemplate('two'),
makeTemplate('three'),
makeTemplate('four'),
makeTemplate('five')
]
renderComponent()
const cards = screen.getAllByTestId('linear-get-started-template')
expect(cards).toHaveLength(4)
expect(screen.getByText('one')).toBeInTheDocument()
expect(screen.queryByText('five')).not.toBeInTheDocument()
})
it('loads a template with its source module when a card is clicked', async () => {
const user = userEvent.setup()
renderComponent()
const cards = screen.getAllByTestId('linear-get-started-template')
await user.click(cards[1])
expect(loadWorkflowTemplate).toHaveBeenCalledWith('b.app', 'mymod')
})
it('defaults the source module when a card has none', async () => {
const user = userEvent.setup()
renderComponent()
const cards = screen.getAllByTestId('linear-get-started-template')
await user.click(cards[0])
expect(loadWorkflowTemplate).toHaveBeenCalledWith('a.app', 'default')
})
it('disables cards and actions while a template is loading', async () => {
const user = userEvent.setup()
templatesState.loadingTemplateId = 'a.app'
renderComponent()
const cards = screen.getAllByTestId('linear-get-started-template')
await user.click(cards[1])
expect(loadWorkflowTemplate).not.toHaveBeenCalled()
expect(screen.getByTestId('linear-get-started-import')).toBeDisabled()
expect(screen.getByTestId('linear-get-started-discover')).toBeDisabled()
})
it('shows an error toast when loading a template fails', async () => {
const user = userEvent.setup()
loadWorkflowTemplate.mockResolvedValue(false)
renderComponent()
await user.click(screen.getAllByTestId('linear-get-started-template')[0])
await vi.waitFor(() =>
expect(addToast).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'linearMode.getStarted.loadFailed'
})
)
)
})
it('opens a workflow via the command store when import is clicked', async () => {
const user = userEvent.setup()
renderComponent()
await user.click(screen.getByTestId('linear-get-started-import'))
expect(executeCommand).toHaveBeenCalledWith('Comfy.OpenWorkflow')
})
it('opens the template selector when discover all is clicked', async () => {
const user = userEvent.setup()
renderComponent()
await user.click(screen.getByTestId('linear-get-started-discover'))
expect(showDialog).toHaveBeenCalledWith('appbuilder')
})
})

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import LazyImage from '@/components/common/LazyImage.vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import {
getBaseThumbnailSrc,
getEffectiveSourceModule,
getTemplateTitle,
isAppTemplate
} from '@/platform/workflow/templates/utils/templateUtil'
import { useCommandStore } from '@/stores/commandStore'
const FEATURED_COUNT = 4
const { t } = useI18n()
const templatesStore = useWorkflowTemplatesStore()
const toastStore = useToastStore()
const commandStore = useCommandStore()
const {
isTemplatesLoaded,
loadingTemplateId,
loadTemplates,
loadWorkflowTemplate
} = useTemplateWorkflows()
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
onMounted(() => void loadTemplates())
const featuredTemplates = computed(() => {
const all = templatesStore.enhancedTemplates
const apps = all.filter(isAppTemplate)
return (apps.length ? apps : all).slice(0, FEATURED_COUNT)
})
const isLoadingTemplate = computed(() => loadingTemplateId.value !== null)
function titleOf(template: TemplateInfo) {
return getTemplateTitle(template, getEffectiveSourceModule(template))
}
async function selectTemplate(template: TemplateInfo) {
const loaded = await loadWorkflowTemplate(
template.name,
getEffectiveSourceModule(template)
)
if (!loaded) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('linearMode.getStarted.loadFailed')
})
}
}
</script>
<template>
<div
data-testid="linear-get-started"
class="flex size-full min-h-0 flex-col items-center overflow-y-auto px-8 pt-[clamp(96px,18vh,200px)] pb-16"
>
<div class="flex w-full max-w-4xl flex-col items-center gap-8">
<div class="flex flex-col items-center gap-1 text-center">
<h1 class="text-5xl leading-none font-medium text-base-foreground">
{{ t('linearMode.getStarted.title') }}
</h1>
<p class="max-w-lg text-sm/relaxed text-muted-foreground">
{{ t('linearMode.getStarted.subtitle') }}
</p>
</div>
<div class="flex flex-wrap items-center justify-center gap-3">
<Button
type="button"
variant="textonly"
size="md"
class="rounded-full bg-interface-menu-component-surface-selected px-3 hover:bg-interface-menu-component-surface-selected"
>
<i class="icon-[lucide--layout-template] size-3.5" />
{{ t('linearMode.getStarted.templates') }}
</Button>
<Button
type="button"
variant="textonly"
size="md"
class="rounded-full bg-interface-menu-component-surface-hovered px-3 opacity-70 hover:bg-interface-menu-component-surface-selected hover:opacity-100"
data-testid="linear-get-started-import"
:disabled="isLoadingTemplate"
@click="commandStore.execute('Comfy.OpenWorkflow')"
>
<i class="icon-[lucide--upload] size-3.5" />
{{ t('linearMode.getStarted.importWorkflow') }}
</Button>
</div>
<div class="flex flex-col items-center gap-8">
<div class="flex flex-wrap items-center justify-center gap-5">
<template v-if="isTemplatesLoaded">
<button
v-for="template in featuredTemplates"
:key="template.name"
type="button"
data-testid="linear-get-started-template"
class="group relative flex size-50 cursor-pointer appearance-none flex-col overflow-hidden rounded-2xl border-none bg-base-background p-0 text-left disabled:cursor-default"
:disabled="isLoadingTemplate"
@click="selectTemplate(template)"
>
<div
class="absolute inset-0 overflow-hidden rounded-2xl bg-dialog-surface"
>
<LazyImage
:src="getBaseThumbnailSrc(template)"
alt=""
image-class="size-full object-cover transition-transform duration-300 ease-out group-hover:scale-105"
/>
</div>
<div
class="pointer-events-none absolute inset-0 rounded-2xl bg-linear-to-b from-black/40 via-transparent to-black/50"
/>
<i
v-if="loadingTemplateId === template.name"
class="absolute inset-0 z-20 m-auto icon-[lucide--loader-2] size-8 animate-spin text-white"
/>
<span
class="relative z-10 mt-auto w-full truncate p-3 text-sm font-semibold text-white"
>
{{ titleOf(template) }}
</span>
</button>
</template>
<template v-else>
<div
v-for="n in FEATURED_COUNT"
:key="n"
class="size-50 animate-pulse rounded-2xl bg-dialog-surface"
/>
</template>
</div>
<Button
variant="textonly"
size="lg"
data-testid="linear-get-started-discover"
:disabled="isLoadingTemplate"
@click="templateSelectorDialog.show('appbuilder')"
>
{{ t('linearMode.getStarted.discoverAll') }}
<i class="icon-[lucide--arrow-right] size-4" />
</Button>
</div>
</div>
</div>
</template>

View File

@@ -5,62 +5,53 @@ import { createI18n } from 'vue-i18n'
import LinearWelcome from './LinearWelcome.vue'
const { hasNodes, hasOutputs, enterBuilder } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { ref } = require('vue')
const { appModeState, enterBuilder } = vi.hoisted(() => ({
appModeState: { hasNodes: false, hasOutputs: false },
enterBuilder: vi.fn()
}))
vi.mock('@/stores/appModeStore', async () => {
const { computed, reactive } = await import('vue')
return {
hasNodes: ref(false),
hasOutputs: ref(false),
enterBuilder: vi.fn()
useAppModeStore: () =>
reactive({
hasNodes: computed(() => appModeState.hasNodes),
hasOutputs: computed(() => appModeState.hasOutputs),
enterBuilder
})
}
})
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ setMode: vi.fn() })
}))
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
useWorkflowTemplateSelectorDialog: () => ({ show: vi.fn() })
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => ({
hasNodes,
hasOutputs,
enterBuilder
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
activeWorkflow: null
})
}))
const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false })
function renderComponent(
opts: { hasNodes?: boolean; hasOutputs?: boolean } = {}
) {
hasNodes.value = opts.hasNodes ?? false
hasOutputs.value = opts.hasOutputs ?? false
appModeState.hasNodes = opts.hasNodes ?? false
appModeState.hasOutputs = opts.hasOutputs ?? false
return render(LinearWelcome, {
global: { plugins: [i18n] }
global: {
plugins: [i18n],
stubs: {
LinearGetStarted: {
template: '<div data-testid="get-started-stub" />'
}
}
}
})
}
describe('LinearWelcome', () => {
beforeEach(() => {
hasNodes.value = false
hasOutputs.value = false
appModeState.hasNodes = false
appModeState.hasOutputs = false
vi.clearAllMocks()
})
it('shows empty workflow text when there are no nodes', () => {
it('shows the get started page when there are no nodes', () => {
renderComponent({ hasNodes: false })
expect(
screen.getByTestId('linear-welcome-empty-workflow')
).toBeInTheDocument()
expect(screen.getByTestId('get-started-stub')).toBeInTheDocument()
expect(screen.queryByTestId('linear-welcome')).not.toBeInTheDocument()
expect(
screen.queryByTestId('linear-welcome-build-app')
).not.toBeInTheDocument()
@@ -68,12 +59,19 @@ describe('LinearWelcome', () => {
it('shows build app button when there are nodes but no outputs', () => {
renderComponent({ hasNodes: true, hasOutputs: false })
expect(
screen.queryByTestId('linear-welcome-empty-workflow')
).not.toBeInTheDocument()
expect(screen.queryByTestId('get-started-stub')).not.toBeInTheDocument()
expect(screen.getByTestId('linear-welcome-build-app')).toBeInTheDocument()
})
it('shows the ready-to-run card without the build button when the app has outputs', () => {
renderComponent({ hasNodes: true, hasOutputs: true })
expect(screen.getByTestId('linear-welcome')).toBeInTheDocument()
expect(
screen.queryByTestId('linear-welcome-build-app')
).not.toBeInTheDocument()
expect(screen.queryByTestId('get-started-stub')).not.toBeInTheDocument()
})
it('clicking build app button calls enterBuilder', async () => {
const user = userEvent.setup()
renderComponent({ hasNodes: true, hasOutputs: false })

View File

@@ -1,108 +1,68 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useAppModeStore } from '@/stores/appModeStore'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { cn } from '@comfyorg/tailwind-utils'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import LinearGetStarted from '@/renderer/extensions/linearMode/LinearGetStarted.vue'
import { useAppModeStore } from '@/stores/appModeStore'
const { t } = useI18n()
const { setMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs, hasNodes } = storeToRefs(appModeStore)
const workflowStore = useWorkflowStore()
const isAppDefault = computed(
() => workflowStore.activeWorkflow?.initialMode === 'app'
const showGetStarted = computed(() => !hasOutputs.value && !hasNodes.value)
const card = computed(() =>
hasOutputs.value
? {
icon: 'icon-[lucide--play]',
title: t('linearMode.welcome.title'),
description: t('linearMode.welcome.description')
}
: {
icon: 'icon-[lucide--panels-top-left]',
title: t('linearMode.buildPrompt.title'),
description: t('linearMode.buildPrompt.description')
}
)
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
</script>
<template>
<LinearGetStarted v-if="showGetStarted" />
<div
v-else
role="article"
data-testid="linear-welcome"
class="mx-auto flex h-full max-w-lg flex-col items-center justify-center gap-6 p-8 text-center"
class="flex size-full flex-col items-center justify-center p-8 text-center"
>
<div class="flex flex-col gap-2">
<h2 class="text-3xl font-semibold text-muted-foreground">
{{ t('linearMode.welcome.title') }}
</h2>
</div>
<div class="flex max-w-md flex-col gap-3 text-[14px] text-muted-foreground">
<p class="mt-0">{{ t('linearMode.welcome.message') }}</p>
<p class="mt-0">{{ t('linearMode.welcome.controls') }}</p>
<p class="mt-0">{{ t('linearMode.welcome.sharing') }}</p>
</div>
<div v-if="hasOutputs" class="flex flex-row gap-2 text-[14px]">
<p class="mt-0 text-base-foreground">
<i18n-t keypath="linearMode.welcome.getStarted" tag="span">
<template #runButton>
<span
class="mx-0.5 inline-flex -translate-y-0.5 transform cursor-default items-center rounded-sm bg-primary-background px-3.5 py-0.5 text-2xs font-medium text-base-foreground"
>
{{ t('menu.run') }}
</span>
</template>
</i18n-t>
</p>
</div>
<template v-else>
<p
v-if="!hasNodes"
data-testid="linear-welcome-empty-workflow"
class="mt-0 max-w-md text-sm text-base-foreground"
<div class="flex w-full max-w-md flex-col items-center gap-6">
<div
class="flex w-full flex-col gap-5 rounded-2xl border border-border-subtle bg-base-background p-5 text-left"
>
{{ t('linearMode.emptyWorkflowExplanation') }}
</p>
<p
v-if="hasNodes && isAppDefault"
class="mt-0 max-w-md text-sm text-base-foreground"
>
<i18n-t keypath="linearMode.welcome.noOutputs" tag="span">
<template #count>
<span class="font-bold text-warning-background">{{
t('linearMode.welcome.oneOutput')
}}</span>
</template>
</i18n-t>
</p>
<div class="flex flex-row gap-2">
<Button
data-testid="linear-welcome-back-to-workflow"
variant="textonly"
size="lg"
@click="setMode('graph')"
<div
class="flex size-12 items-center justify-center rounded-xl bg-secondary-background-hover"
>
{{ t('linearMode.backToWorkflow') }}
</Button>
<i :class="cn(card.icon, 'size-6 text-base-foreground')" />
</div>
<h2 class="m-0 p-0 text-xl font-semibold text-base-foreground">
{{ card.title }}
</h2>
<p class="m-0 p-0 text-sm/relaxed text-base-foreground">
{{ card.description }}
</p>
<Button
v-if="!hasNodes"
data-testid="linear-welcome-load-template"
variant="secondary"
size="lg"
@click="templateSelectorDialog.show('appbuilder')"
>
{{ t('linearMode.loadTemplate') }}
</Button>
<Button
v-else
v-if="!hasOutputs"
data-testid="linear-welcome-build-app"
variant="primary"
variant="inverted"
size="lg"
class="w-full"
@click="appModeStore.enterBuilder()"
>
<i class="icon-[lucide--hammer]" />
{{ t('linearMode.welcome.buildApp') }}
<div
class="absolute -top-2 -right-2 rounded-full bg-base-foreground px-1 text-2xs text-base-background"
>
{{ t('g.experimental') }}
</div>
{{ t('linearMode.buildPrompt.button') }}
</Button>
</div>
</template>
</div>
</div>
</template>

View File

@@ -110,7 +110,7 @@ function dragDrop(e: DragEvent) {
</div>
<Splitter
:key="splitterKey"
class="bg-comfy-menu-secondary-bg h-[calc(100%-var(--workflow-tabs-height))] w-full border-none"
class="h-[calc(100%-var(--workflow-tabs-height))] w-full border-none bg-base-background"
@resizestart="$event.originalEvent.preventDefault()"
@resizeend="onResizeEnd"
>
@@ -145,10 +145,9 @@ function dragDrop(e: DragEvent) {
/>
</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"
class="relative flex min-w-[20vw] flex-col gap-4 bg-interface-canvas-background text-muted-foreground outline-none"
@drop="dragDrop"
>
<LinearProgressBar