Compare commits

...

10 Commits

Author SHA1 Message Date
pythongosssss
a483a207a9 feat(app-mode): order get-started templates from a remote flag
- Add remoteUserData module: PostHog feature-flag JSON payloads with
  snapshot/reactive reads, schema validation, and default fallback
- Wire the app-mode-template-order flag into LinearGetStarted via
  resolvePrioritizedIds; gate cards on readiness to avoid reorder flash
- Register the payload source from PostHogTelemetryProvider; add a 3s
  readiness backstop and reload flags once auth settles anonymous
- Cover with unit + e2e tests and document usage in docs/REMOTE_USER_DATA.md
2026-07-08 08:24:00 -07:00
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
25 changed files with 1544 additions and 347 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
@@ -236,6 +234,13 @@ export class AppModeHelper {
await this.toggleAppMode()
}
/** Featured template `name`s in the order the cards are rendered. */
async getStartedTemplateNames(): Promise<string[]> {
return this.getStartedTemplateCards.evaluateAll((cards) =>
cards.map((card) => card.getAttribute('data-template-name') ?? '')
)
}
/**
* Get the actions menu trigger for a widget in the app mode widget list.
* @param widgetName Text shown in the widget label (e.g. "seed").

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,39 +25,94 @@ 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()
})
test('Remote order flag reorders the featured templates', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.getStarted).toBeVisible()
const naturalOrder = await comfyPage.appMode.getStartedTemplateNames()
expect(naturalOrder.length).toBeGreaterThan(1)
const reversed = [...naturalOrder].reverse()
await comfyPage.featureFlags.setFlags({
'app-mode-template-order': { templateIds: reversed }
})
// Snapshot mode reads the payload on mount, so remount the page by leaving
// and re-entering app mode.
await comfyPage.appMode.toggleAppMode()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.getStarted).toBeVisible()
await expect
.poll(() => comfyPage.appMode.getStartedTemplateNames())
.toEqual(reversed)
})
test('Invalid remote order payload falls back to the default order', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.getStarted).toBeVisible()
const naturalOrder = await comfyPage.appMode.getStartedTemplateNames()
expect(naturalOrder.length).toBeGreaterThan(0)
await comfyPage.featureFlags.setFlags({
'app-mode-template-order': { templateIds: 'not-an-array' }
})
await comfyPage.appMode.toggleAppMode()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.getStarted).toBeVisible()
await expect
.poll(() => comfyPage.appMode.getStartedTemplateNames())
.toEqual(naturalOrder)
})
test('Empty workflow dialog blocks entering builder on an empty graph', async ({
comfyPage
}) => {

107
docs/REMOTE_USER_DATA.md Normal file
View File

@@ -0,0 +1,107 @@
# Remote User Data (PostHog payloads)
Fetch per-user / per-cohort JSON from PostHog feature-flag payloads to tune UI
behavior (element ordering, which content to surface, survey shape, …) from the
PostHog dashboard **without a frontend release**.
This is for personalization hints only. Every key has a hardcoded default that
must always be shippable — never gate anything critical on a remote value.
## For engineers
### Consuming a value
```ts
import { z } from 'zod'
import { useRemoteUserData } from '@/platform/remoteUserData/useRemoteUserData'
const { data, isLoaded } = useRemoteUserData({
key: 'app-mode-template-order',
schema: z.object({ templateIds: z.array(z.string()) }),
defaultValue: { templateIds: DEFAULT_APP_MODE_TEMPLATE_IDS }
// mode: 'snapshot' (default) | 'reactive'
})
```
- `data` — validated payload, or `defaultValue` if absent/invalid. Never throws.
- `isLoaded` — shared readiness signal. Instantly `true` in OSS/desktop or when
PostHog is disabled; in cloud it is `false` until the first authoritative flag
response (after auth resolves), then `true` forever. Gate UI that must not
render-then-reorder on this (show a skeleton while `false`). A blocked or slow
`/flags` request holds `false` for up to the ~3s backstop, so only gate
reorderable secondary content — render primary, above-the-fold UI on defaults
immediately rather than behind a skeleton.
### Registering a key
Add the flag key to `REMOTE_USER_DATA_KEYS` in
`src/platform/remoteUserData/keys.ts`. Registration is what makes the cloud
provider fetch that key's payload, and keeps the flag inventory greppable.
### snapshot vs reactive
- **`snapshot`** (default) — resolves once (when `isLoaded` flips true, or
immediately if already loaded) then freezes for the instance's lifetime. Later
reloads, including values arriving after the timeout backstop, do not touch it.
Use for anything the user interacts with mid-flow: surveys, welcome tiles,
modal content. A fresh instance (e.g. reopening a flow) takes a fresh snapshot.
- **`reactive`** — tracks every flag reload. Opt-in, for passive UI where a rare
late update is harmless (e.g. sidebar ordering).
Snapshot is the default because it cannot mutate mid-interaction.
### Ordering payloads
For "ordered list of ids" payloads whose ids reference content that ships
separately, resolve through `resolvePrioritizedIds(payloadIds, defaultIds,
validIds, limit)`: it drops ids missing from the registry and backfills from the
defaults, so a stale or typo'd payload can never produce an empty/broken list.
### Segmenting telemetry
Under `snapshot`, a user whose `/flags` response beat the ~3s timeout sees the
targeted config; one whose didn't sees defaults. Give flow-driving payloads a
`version`/`variant` field and attach it to the flow's telemetry events so
analysis segments by what the user actually saw.
### Dev override
In dev builds only:
```js
localStorage.setItem(
'ff:app-mode-template-order',
JSON.stringify({ templateIds: ['flux-schnell'] })
)
localStorage.removeItem('ff:app-mode-template-order')
```
### Constraints
- Values exist only in cloud builds; OSS/desktop always get defaults at zero cost.
- Targeting by cohort/person properties only matches **identified** (logged-in)
users. Anonymous users get percentage rollout at best.
## For marketing / product
You change behavior entirely from the PostHog dashboard — no deploy.
1. **Feature Flags → New feature flag.** Use the exact key an engineer registered
(e.g. `app-mode-template-order`). Kebab-case, describes what it tunes.
2. **Release conditions** — target by cohort, person properties (e.g.
`subscription_tier`, or survey answers already set on the person), or a
percentage rollout. Person-property targeting only reaches logged-in users.
3. **Payload** — add the JSON payload. Edit it anytime; changes take effect on
users' next session without a release.
4. **A/B experiments** — a multivariate flag carries one payload per variant, so
variants can be measured against existing telemetry events.
### Payload shapes per key
| Key | JSON shape | Notes |
| ------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `app-mode-template-order` | `{ "templateIds": ["flux-schnell", "sdxl-turbo", …] }` | Ordered template ids for the app-mode welcome screen. Unknown ids are dropped and the default list backfills, so a typo can't blank the screen. Valid template ids come from the template registry — ask an engineer for the current list. |
Keep every payload valid JSON — a malformed or unexpected payload is ignored and
the user silently gets the shipped default.

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

@@ -0,0 +1,8 @@
/**
* PostHog feature-flag keys whose JSON payloads are exposed via
* {@link useRemoteUserData}. The cloud provider only collects payloads for keys
* listed here.
*/
export const REMOTE_USER_DATA_KEYS = ['app-mode-template-order'] as const
export type RemoteUserDataKey = (typeof REMOTE_USER_DATA_KEYS)[number]

View File

@@ -0,0 +1,43 @@
import { computed, ref, shallowRef } from 'vue'
import type { Ref } from 'vue'
/**
* Bridge between the cloud PostHog provider (which produces per-user/per-cohort
* flag payloads) and {@link useRemoteUserData} consumers (which run in any
* build). The provider registers a source; consumers read it and fall back to
* defaults when none was registered.
*
* Must never import posthog-js — it stays in OSS/desktop bundles.
*/
export interface PayloadSource {
payloads: Ref<Record<string, unknown>>
}
// shallowRef so reactive-mode consumers track source registration without
// unwrapping the nested `payloads` ref.
const _payloadSource = shallowRef<PayloadSource | null>(null)
/**
* Ready by default: without a PostHog token no source ever registers, so
* defaults are final and consumers must not wait. The cloud provider marks it
* pending, then ready after the first flag response or a timeout.
*/
const _ready = ref(true)
export const remoteUserDataReady = computed(() => _ready.value)
export function setPayloadSource(source: PayloadSource | null): void {
_payloadSource.value = source
}
export function getPayloadSource(): PayloadSource | null {
return _payloadSource.value
}
export function markRemoteUserDataPending(): void {
_ready.value = false
}
export function markRemoteUserDataReady(): void {
_ready.value = true
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { resolvePrioritizedIds } from './resolvePrioritizedIds'
const valid = new Set(['a', 'b', 'c', 'd'])
describe('resolvePrioritizedIds', () => {
it('honors payload order and caps at n', () => {
expect(
resolvePrioritizedIds(['c', 'a'], ['a', 'b', 'd'], valid, 3)
).toEqual(['c', 'a', 'b'])
})
it('drops ids that are not in the valid set', () => {
expect(resolvePrioritizedIds(['ghost', 'b'], ['a'], valid, 5)).toEqual([
'b',
'a'
])
})
it('backfills from defaults up to n', () => {
expect(resolvePrioritizedIds([], ['a', 'b', 'c'], valid, 2)).toEqual([
'a',
'b'
])
})
it('deduplicates across payload and defaults', () => {
expect(resolvePrioritizedIds(['a', 'a'], ['a', 'b'], valid, 5)).toEqual([
'a',
'b'
])
})
it('never yields an empty list when defaults are valid', () => {
expect(resolvePrioritizedIds(['ghost'], ['a', 'b'], valid, 2)).toEqual([
'a',
'b'
])
})
it('yields an empty list when nothing valid remains', () => {
expect(resolvePrioritizedIds(['ghost'], ['also-ghost'], valid, 5)).toEqual(
[]
)
expect(resolvePrioritizedIds(['a'], ['b'], valid, 0)).toEqual([])
})
})

View File

@@ -0,0 +1,26 @@
/**
* Resolves a payload-driven ordering of ids into a safe, deduplicated list.
*
* Payload ids reference content (e.g. templates) that ships independently of the
* flag, so a stale or typo'd payload must never produce an empty or broken list:
* ids absent from `validIds` are dropped, then `defaultIds` backfill up to
* `limit`. Payload order wins; defaults fill the remainder.
*/
export function resolvePrioritizedIds(
payloadIds: readonly string[],
defaultIds: readonly string[],
validIds: ReadonlySet<string>,
limit: number
): string[] {
const result: string[] = []
const seen = new Set<string>()
for (const id of [...payloadIds, ...defaultIds]) {
if (result.length >= limit) break
if (seen.has(id) || !validIds.has(id)) continue
seen.add(id)
result.push(id)
}
return result
}

View File

@@ -0,0 +1,167 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { z } from 'zod'
import {
markRemoteUserDataPending,
markRemoteUserDataReady,
remoteUserDataReady,
setPayloadSource
} from './payloadSource'
import { useRemoteUserData } from './useRemoteUserData'
const KEY = 'app-mode-template-order'
const schema = z.object({ templateIds: z.array(z.string()) })
const defaultValue = { templateIds: ['default-a', 'default-b'] }
function registerSource(initial: Record<string, unknown> = {}) {
const payloads = ref<Record<string, unknown>>(initial)
setPayloadSource({ payloads })
return payloads
}
beforeEach(() => {
setPayloadSource(null)
markRemoteUserDataReady()
})
afterEach(() => {
localStorage.clear()
vi.restoreAllMocks()
})
describe('useRemoteUserData', () => {
it('returns the default and is loaded when no source is registered', () => {
const { data, isLoaded } = useRemoteUserData({
key: KEY,
schema,
defaultValue
})
expect(data.value).toEqual(defaultValue)
expect(isLoaded.value).toBe(true)
})
it('resolves a valid payload from the source', () => {
registerSource({ [KEY]: { templateIds: ['x', 'y'] } })
const { data } = useRemoteUserData({ key: KEY, schema, defaultValue })
expect(data.value).toEqual({ templateIds: ['x', 'y'] })
})
it('falls back to the default and warns on an invalid payload', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
registerSource({ [KEY]: { templateIds: 'not-an-array' } })
const { data } = useRemoteUserData({ key: KEY, schema, defaultValue })
expect(data.value).toEqual(defaultValue)
expect(warn).toHaveBeenCalled()
})
it('prefers a dev override over the source payload', () => {
registerSource({ [KEY]: { templateIds: ['from-source'] } })
localStorage.setItem(
`ff:${KEY}`,
JSON.stringify({ templateIds: ['from-override'] })
)
const { data } = useRemoteUserData({ key: KEY, schema, defaultValue })
expect(data.value).toEqual({ templateIds: ['from-override'] })
})
describe('reactive mode', () => {
it('tracks payload reloads', async () => {
const payloads = registerSource({ [KEY]: { templateIds: ['v1'] } })
const { data } = useRemoteUserData({
key: KEY,
schema,
defaultValue,
mode: 'reactive'
})
expect(data.value).toEqual({ templateIds: ['v1'] })
payloads.value = { [KEY]: { templateIds: ['v2'] } }
await nextTick()
expect(data.value).toEqual({ templateIds: ['v2'] })
})
it('warns once for a persistently invalid payload across reloads', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const payloads = registerSource({ [KEY]: { templateIds: 'bad' } })
const { data } = useRemoteUserData({
key: KEY,
schema,
defaultValue,
mode: 'reactive'
})
expect(data.value).toEqual(defaultValue)
payloads.value = { [KEY]: { templateIds: 'bad' } }
await nextTick()
expect(data.value).toEqual(defaultValue)
expect(warn).toHaveBeenCalledOnce()
})
})
describe('snapshot mode', () => {
it('snapshots at creation when already loaded', () => {
registerSource({ [KEY]: { templateIds: ['at-create'] } })
const { data } = useRemoteUserData({ key: KEY, schema, defaultValue })
expect(data.value).toEqual({ templateIds: ['at-create'] })
})
it('resolves once when readiness flips, then freezes across reloads', async () => {
markRemoteUserDataPending()
const payloads = registerSource()
const { data } = useRemoteUserData({ key: KEY, schema, defaultValue })
expect(data.value).toEqual(defaultValue)
payloads.value = { [KEY]: { templateIds: ['authoritative'] } }
markRemoteUserDataReady()
await nextTick()
expect(data.value).toEqual({ templateIds: ['authoritative'] })
payloads.value = { [KEY]: { templateIds: ['late-reload'] } }
await nextTick()
expect(data.value).toEqual({ templateIds: ['authoritative'] })
})
it('keeps defaults resolved at a timeout flip even when values arrive later', async () => {
markRemoteUserDataPending()
const payloads = registerSource()
const { data } = useRemoteUserData({ key: KEY, schema, defaultValue })
markRemoteUserDataReady()
await nextTick()
expect(data.value).toEqual(defaultValue)
payloads.value = { [KEY]: { templateIds: ['too-late'] } }
await nextTick()
expect(data.value).toEqual(defaultValue)
})
})
describe('readiness', () => {
it('only flips once and never back', async () => {
markRemoteUserDataPending()
expect(remoteUserDataReady.value).toBe(false)
markRemoteUserDataReady()
expect(remoteUserDataReady.value).toBe(true)
markRemoteUserDataReady()
expect(remoteUserDataReady.value).toBe(true)
})
})
})

View File

@@ -0,0 +1,88 @@
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import type { ZodType } from 'zod'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
import type { RemoteUserDataKey } from './keys'
import { getPayloadSource, remoteUserDataReady } from './payloadSource'
/**
* `snapshot` (default): `data` resolves once when readiness flips true (or
* immediately if already ready) and is then frozen. Use for anything the user
* interacts with mid-flow (surveys, welcome tiles, modal content).
*
* `reactive`: `data` tracks every flag reload. Use only where a late update is
* harmless (e.g. sidebar ordering).
*/
type RemoteUserDataMode = 'snapshot' | 'reactive'
interface UseRemoteUserDataOptions<T> {
key: RemoteUserDataKey
schema: ZodType<T>
defaultValue: T
mode?: RemoteUserDataMode
}
interface UseRemoteUserDataResult<T> {
data: Readonly<Ref<T>>
isLoaded: Readonly<Ref<boolean>>
}
/**
* Reads a per-user/per-cohort JSON payload for `key`, validated against `schema`,
* falling back to `defaultValue`. `isLoaded` is the shared readiness signal:
* instantly true when no PostHog source exists, otherwise true once the first
* authoritative flag response arrives.
*
* Never throws — a hand-edited payload that fails validation logs a warning and
* resolves to the default.
*/
export function useRemoteUserData<T>(
options: UseRemoteUserDataOptions<T>
): UseRemoteUserDataResult<T> {
const { key, schema, defaultValue, mode = 'snapshot' } = options
// Reactive mode re-resolves on every flag reload; dedupe by serialized value
// so a persistently invalid payload warns once rather than on each reload.
let lastWarnedRaw: string | undefined
function resolve(): T {
const override = getDevOverride<unknown>(key)
const raw =
override !== undefined
? override
: getPayloadSource()?.payloads.value[key]
if (raw === undefined) return defaultValue
const parsed = schema.safeParse(raw)
if (parsed.success) return parsed.data
const rawKey = JSON.stringify(raw)
if (rawKey !== lastWarnedRaw) {
lastWarnedRaw = rawKey
console.warn(
`[remoteUserData] Invalid payload for "${key}":`,
parsed.error
)
}
return defaultValue
}
if (mode === 'reactive') {
return { data: computed(resolve), isLoaded: remoteUserDataReady }
}
const data = ref(
remoteUserDataReady.value ? resolve() : defaultValue
) as Ref<T>
if (!remoteUserDataReady.value) {
const stop = watch(remoteUserDataReady, (ready) => {
if (!ready) return
data.value = resolve()
stop()
})
}
return { data, isLoaded: remoteUserDataReady }
}

View File

@@ -15,9 +15,14 @@ const hoisted = vi.hoisted(() => {
const mockReset = vi.fn()
const mockOnUserResolved = vi.fn()
const mockOnUserLogout = vi.fn()
const mockOnFeatureFlags = vi.fn()
const mockGetFeatureFlagResult = vi.fn()
const mockReloadFeatureFlags = vi.fn()
const refs = {
tier: null as unknown as Ref<string | null>,
remoteConfig: null as unknown as Ref<Record<string, unknown> | null>
remoteConfig: null as unknown as Ref<Record<string, unknown> | null>,
resolvedUserInfo: null as unknown as Ref<{ id: string } | null>,
isInitialized: null as unknown as Ref<boolean>
}
return {
@@ -30,6 +35,9 @@ const hoisted = vi.hoisted(() => {
mockReset,
mockOnUserResolved,
mockOnUserLogout,
mockOnFeatureFlags,
mockGetFeatureFlagResult,
mockReloadFeatureFlags,
refs,
mockPosthog: {
default: {
@@ -38,7 +46,10 @@ const hoisted = vi.hoisted(() => {
identify: mockIdentify,
register: mockRegister,
people: { set: mockPeopleSet, set_once: mockPeopleSetOnce },
reset: mockReset
reset: mockReset,
onFeatureFlags: mockOnFeatureFlags,
getFeatureFlagResult: mockGetFeatureFlagResult,
reloadFeatureFlags: mockReloadFeatureFlags
}
}
}
@@ -47,7 +58,16 @@ const hoisted = vi.hoisted(() => {
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: () => ({
onUserResolved: hoisted.mockOnUserResolved,
onUserLogout: hoisted.mockOnUserLogout
onUserLogout: hoisted.mockOnUserLogout,
resolvedUserInfo: hoisted.refs.resolvedUserInfo
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
get isInitialized() {
return hoisted.refs.isInitialized.value
}
})
}))
@@ -65,6 +85,13 @@ vi.mock('@/composables/billing/useBillingContext', async () => {
return { useBillingContext: () => ({ tier: hoisted.refs.tier }) }
})
import {
getPayloadSource,
markRemoteUserDataReady,
remoteUserDataReady,
setPayloadSource
} from '@/platform/remoteUserData/payloadSource'
import { PostHogTelemetryProvider } from './PostHogTelemetryProvider'
function createProvider(
@@ -79,16 +106,24 @@ function createProvider(
describe('PostHogTelemetryProvider', () => {
beforeEach(() => {
// Keep each provider's 3s readiness backstop from firing in a later test.
vi.useFakeTimers()
vi.clearAllMocks()
hoisted.refs.remoteConfig.value = null
// Fresh tier ref per test: each provider registers an undisposed tier
// watch, so a shared ref would leak watchers across tests.
hoisted.refs.tier = ref<string | null>(null)
hoisted.refs.resolvedUserInfo = ref<{ id: string } | null>(null)
hoisted.refs.isInitialized = ref(false)
window.__CONFIG__ = {
posthog_project_token: 'phc_test_token'
} as typeof window.__CONFIG__
})
afterEach(() => {
vi.useRealTimers()
})
describe('initialization', () => {
it('disables itself when posthog_project_token is not provided', async () => {
const provider = createProvider({ posthog_project_token: undefined })
@@ -634,6 +669,124 @@ describe('PostHogTelemetryProvider', () => {
})
})
describe('remote user data', () => {
beforeEach(() => {
hoisted.mockGetFeatureFlagResult.mockReturnValue(undefined)
})
afterEach(() => {
setPayloadSource(null)
markRemoteUserDataReady()
})
it('disables the anonymous first-load flag fetch on init', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(hoisted.mockInit).toHaveBeenCalledWith(
'phc_test_token',
expect.objectContaining({
advanced_disable_feature_flags_on_first_load: true
})
)
})
it('registers a reactive payload source', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(getPayloadSource()).not.toBeNull()
})
it('stays pending until the first flag response, then becomes ready', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(remoteUserDataReady.value).toBe(false)
const onFlags = hoisted.mockOnFeatureFlags.mock.calls[0][0]
onFlags()
expect(remoteUserDataReady.value).toBe(true)
})
it('collects known-key payloads on the flag response', async () => {
hoisted.mockGetFeatureFlagResult.mockImplementation((key: string) =>
key === 'app-mode-template-order'
? {
key,
enabled: true,
variant: undefined,
payload: { templateIds: ['a'] }
}
: undefined
)
createProvider()
await vi.dynamicImportSettled()
hoisted.mockOnFeatureFlags.mock.calls[0][0]()
expect(getPayloadSource()?.payloads.value).toEqual({
'app-mode-template-order': { templateIds: ['a'] }
})
})
it('reloads flags when auth settles anonymous', async () => {
createProvider()
await vi.dynamicImportSettled()
hoisted.refs.isInitialized.value = true
// Two flushes: one for the `until` watcher, one for its awaiting continuation.
await nextTick()
await nextTick()
expect(hoisted.mockReloadFeatureFlags).toHaveBeenCalledOnce()
})
it('reloads flags when auth is already settled anonymous before init', async () => {
hoisted.refs.isInitialized.value = true
createProvider()
await vi.dynamicImportSettled()
await nextTick()
expect(hoisted.mockReloadFeatureFlags).toHaveBeenCalledOnce()
expect(hoisted.mockOnUserResolved).toHaveBeenCalled()
})
it('does not reload flags when auth settles with a user (identify drives it)', async () => {
hoisted.refs.resolvedUserInfo.value = { id: 'user-1' }
createProvider()
await vi.dynamicImportSettled()
hoisted.refs.isInitialized.value = true
await nextTick()
await nextTick()
expect(hoisted.mockReloadFeatureFlags).not.toHaveBeenCalled()
})
it('marks ready via the timeout backstop when no flags arrive', async () => {
createProvider()
await vi.dynamicImportSettled()
expect(remoteUserDataReady.value).toBe(false)
vi.advanceTimersByTime(3000)
expect(remoteUserDataReady.value).toBe(true)
})
it('marks ready when PostHog fails to load', async () => {
hoisted.mockInit.mockImplementationOnce(() => {
throw new Error('init boom')
})
createProvider()
await vi.dynamicImportSettled()
expect(remoteUserDataReady.value).toBe(true)
})
})
describe('page view', () => {
it('captures legacy page view event with page_name property', async () => {
const provider = createProvider()

View File

@@ -1,5 +1,6 @@
import { until } from '@vueuse/core'
import type { PostHog } from 'posthog-js'
import { watch } from 'vue'
import { ref, watch } from 'vue'
import type { WatchStopHandle } from 'vue'
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
@@ -8,6 +9,13 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { REMOTE_USER_DATA_KEYS } from '@/platform/remoteUserData/keys'
import {
markRemoteUserDataPending,
markRemoteUserDataReady,
setPayloadSource
} from '@/platform/remoteUserData/payloadSource'
import { useAuthStore } from '@/stores/authStore'
import type {
AuthMetadata,
@@ -83,6 +91,19 @@ function readDesktopEntryProps(): DesktopEntryProps | null {
return props
}
// Fall back to defaults if no flag response lands, so a blocked /flags request
// or slow auth can't leave gated UI pending forever.
const REMOTE_USER_DATA_READY_TIMEOUT_MS = 3000
function collectPayloads(posthog: PostHog): Record<string, unknown> {
const payloads: Record<string, unknown> = {}
for (const key of REMOTE_USER_DATA_KEYS) {
const payload = posthog.getFeatureFlagResult(key)?.payload
if (payload !== undefined) payloads[key] = payload
}
return payloads
}
/**
* PostHog Telemetry Provider - Cloud Build Implementation
*
@@ -101,6 +122,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
private disabledEvents = new Set<TelemetryEventName>(DEFAULT_DISABLED_EVENTS)
private desktopEntryProps: DesktopEntryProps | null = null
private stopSubscriptionTierWatch: WatchStopHandle | null = null
private remoteUserDataReadyTimeout: ReturnType<typeof setTimeout> | null =
null
constructor() {
this.configureDisabledEvents(
@@ -116,6 +139,12 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
const apiKey = window.__CONFIG__?.posthog_project_token
if (apiKey) {
// Registered before the async posthog import so late consumers always
// see a source; onFeatureFlags mutates this ref later.
const payloads = ref<Record<string, unknown>>({})
setPayloadSource({ payloads })
this.armRemoteUserDataReadiness()
try {
void import('posthog-js')
.then((posthogModule) => {
@@ -132,6 +161,9 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
...serverConfig,
person_profiles: 'identified_only',
// Fetch flags only after auth resolves, so the first payloads are
// already cohort/person-targeted rather than anonymous-then-reordered.
advanced_disable_feature_flags_on_first_load: true,
// cookie_domain omitted: posthog-js sets a first-party cross-subdomain cookie
// automatically when persistence includes 'cookie' (the default).
// Explicit override interacts badly with posthog-js#3578 where reset() fails
@@ -142,7 +174,13 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.flushEventQueue()
this.registerDesktopEntryProps()
this.posthog.onFeatureFlags(() => {
payloads.value = collectPayloads(this.posthog!)
this.settleRemoteUserDataReady()
})
const currentUser = useCurrentUser()
void this.reloadFeatureFlagsWhenAnonymous(currentUser)
currentUser.onUserResolved((user) => {
if (this.posthog && user.id) {
this.posthog.identify(user.id)
@@ -166,10 +204,12 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
.catch((error) => {
console.error('Failed to load PostHog:', error)
this.isEnabled = false
this.settleRemoteUserDataReady()
})
} catch (error) {
console.error('Failed to initialize PostHog:', error)
this.isEnabled = false
this.settleRemoteUserDataReady()
}
} else {
console.warn('PostHog API key not provided in runtime config')
@@ -177,6 +217,34 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
}
}
private armRemoteUserDataReadiness(): void {
markRemoteUserDataPending()
this.remoteUserDataReadyTimeout = setTimeout(
() => this.settleRemoteUserDataReady(),
REMOTE_USER_DATA_READY_TIMEOUT_MS
)
}
private settleRemoteUserDataReady(): void {
if (this.remoteUserDataReadyTimeout !== null) {
clearTimeout(this.remoteUserDataReadyTimeout)
this.remoteUserDataReadyTimeout = null
}
markRemoteUserDataReady()
}
// identify() drives the flag fetch for logged-in users; anonymous users are
// never identified, so trigger their fetch once auth settles.
private async reloadFeatureFlagsWhenAnonymous(
currentUser: ReturnType<typeof useCurrentUser>
): Promise<void> {
const authStore = useAuthStore()
await until(() => authStore.isInitialized).toBe(true)
if (!currentUser.resolvedUserInfo.value) {
this.posthog?.reloadFeatureFlags()
}
}
private flushEventQueue(): void {
if (!this.isInitialized || !this.posthog) return

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,250 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import {
markRemoteUserDataPending,
markRemoteUserDataReady,
setPayloadSource
} from '@/platform/remoteUserData/payloadSource'
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 })
}
}
function registerOrder(templateIds: string[]) {
setPayloadSource({
payloads: ref({ 'app-mode-template-order': { templateIds } })
})
}
function renderedTemplateNames(): (string | undefined)[] {
return screen
.getAllByTestId('linear-get-started-template')
.map((card) => card.textContent?.trim())
}
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()
setPayloadSource(null)
markRemoteUserDataReady()
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('orders featured templates by the remote payload, backfilling defaults', () => {
registerOrder(['d.app', 'b.app'])
renderComponent()
expect(renderedTemplateNames()).toEqual([
'd.app',
'b.app',
'a.app',
'e.app'
])
})
it('drops unknown ids from the remote order', () => {
registerOrder(['ghost', 'e.app'])
renderComponent()
expect(renderedTemplateNames()).toEqual([
'e.app',
'a.app',
'b.app',
'd.app'
])
})
it('shows skeletons until the remote order is ready', () => {
markRemoteUserDataPending()
registerOrder(['a.app'])
renderComponent()
expect(screen.queryAllByTestId('linear-get-started-template')).toHaveLength(
0
)
})
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,183 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { z } from 'zod'
import LazyImage from '@/components/common/LazyImage.vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { resolvePrioritizedIds } from '@/platform/remoteUserData/resolvePrioritizedIds'
import { useRemoteUserData } from '@/platform/remoteUserData/useRemoteUserData'
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()
const { data: templateOrder, isLoaded: isOrderLoaded } = useRemoteUserData({
key: 'app-mode-template-order',
schema: z.object({ templateIds: z.array(z.string()) }),
defaultValue: { templateIds: [] }
})
onMounted(() => void loadTemplates())
const featuredTemplates = computed(() => {
const all = templatesStore.enhancedTemplates
const apps = all.filter(isAppTemplate)
const candidates = apps.length ? apps : all
const byName = new Map(
candidates.map((template) => [template.name, template])
)
const orderedNames = resolvePrioritizedIds(
templateOrder.value.templateIds,
candidates.map((template) => template.name),
new Set(byName.keys()),
FEATURED_COUNT
)
return orderedNames.map((name) => byName.get(name)!)
})
const isFeaturedReady = computed(
() => isTemplatesLoaded.value && isOrderLoaded.value
)
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="isFeaturedReady">
<button
v-for="template in featuredTemplates"
:key="template.name"
type="button"
data-testid="linear-get-started-template"
:data-template-name="template.name"
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