Compare commits

..

3 Commits

Author SHA1 Message Date
Benjamin Lu
97a7c3d52d feat: tag checkout platform source 2026-07-01 09:49:20 -07:00
Mobeen Abdullah
9cf5c9a93f refactor(website): tidy customer story review nits (#13324)
## Summary

Small follow-up to #13289 applying two non-blocking review nits from
Alex's review.

## Changes

- **What**: drop the redundant `before:content-['']` on the
customer-story list bullet (Tailwind emits the empty `content`
automatically once another `before:` utility is present), and rename
`HEADER_OFFSET` to `HEADER_OFFSET_PX` in `ArticleNav` so the scroll
constants use consistent unit suffixes.

## Review Focus

Both changes are cosmetic with no behavior change. Confirmed in the
browser that the list bullet still renders identically (6px yellow dot)
without the explicit `content` utility.

## Notes from the #13289 review (left as-is here, open to discussion)

Three other comments from the review are intentionally not changed in
this PR; reasoning below so the decisions are on record:

- **`Category` type in `ArticleNav`**: kept the `ComponentProps<typeof
CategoryNav>` derivation. AGENTS.md says to derive component types via
`vue-component-type-helpers` rather than redefining them, so the current
form follows the styleguide. Happy to switch to a plain named type if
preferred.
- **Section ids in frontmatter vs the body `<Section>`**: kept the
`customers.content.test.ts` parity test. The short TOC labels live only
in frontmatter and Astro can't introspect the rendered MDX body to build
the nav, so the frontmatter `sections` list and the body anchor ids
can't be trivially deduplicated. A real fix would need a remark plugin
(larger, separate change). The test guards against silent drift in the
meantime.
- **`nextStory` throw**: left as a fail-loud, build-time invariant. The
slug always comes from the same `getStaticPaths` collection, so the
throw is effectively unreachable; it surfaces a future-refactor bug
loudly instead of linking to the wrong story.
2026-07-01 12:45:24 +00:00
jaeone94
9e5fb67b76 Show app mode run validation warning (#12557)
## Summary
Adds an app mode validation warning so users can see when a workflow has
errors before running and jump directly back to graph mode to review
them.

## Changes
- **What**: Adds a reusable app mode warning banner above the Run button
when the execution error store reports workflow errors, including
validation and missing asset states.
- **What**: Reuses the existing graph-error navigation flow so the
warning action switches out of app mode and opens the Errors panel in
graph mode.
- **What**: Updates the app mode Run button icon and accessible label in
the warning state while keeping the Run action non-blocking.
- **What**: Adds unit coverage for the warning render/accessibility
state and an E2E flow that triggers a validation failure, dismisses the
overlay, and opens graph errors from the app mode warning.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus
The warning intentionally mirrors graph mode behavior: it surfaces the
error state but does not prevent the user from clicking Run. This avoids
turning display-level validation signals into hard execution blockers.

The warning is driven by the existing `hasAnyError` aggregate, so
missing nodes, missing models, and missing media are included alongside
prompt/node/execution errors.

## Tests
- `pnpm format`
- `pnpm lint`
- `pnpm typecheck`
- `pnpm test:unit`
- `pnpm knip`
- `pnpm test:browser:local
browser_tests/tests/appModeValidationWarning.spec.ts`

## Screenshots

<img width="461" height="994" alt="스크린샷 2026-06-25 오후 7 00 55"
src="https://github.com/user-attachments/assets/f8fc20bf-d572-46b5-9fa4-312e7c4c8076"
/>
2026-07-01 15:24:45 +09:00
36 changed files with 933 additions and 1003 deletions

View File

@@ -15,7 +15,7 @@ const { categories } = defineProps<{
const activeSection = ref(categories[0]?.value ?? '')
const HEADER_OFFSET = -144
const HEADER_OFFSET_PX = -144
const BOTTOM_THRESHOLD_PX = 4
const SCROLL_SAFETY_MS = 1500
@@ -52,7 +52,7 @@ function scrollToSection(id: string) {
const el = document.getElementById(id)
if (el) {
scrollTo(el, {
offset: HEADER_OFFSET,
offset: HEADER_OFFSET_PX,
duration: 0.8,
immediate: prefersReducedMotion(),
onComplete: clearScrollLock

View File

@@ -1,5 +1,5 @@
<li
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow before:content-['']"
class="flex items-start gap-2 text-primary-comfy-canvas before:mt-1.5 before:size-1.5 before:shrink-0 before:rounded-full before:bg-primary-comfy-yellow"
>
<slot />
</li>

View File

@@ -0,0 +1,45 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 9,
"type": "SaveImage",
"pos": {
"0": 64,
"1": 104
},
"size": {
"0": 210,
"1": 58
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": null
}
],
"outputs": [],
"properties": {},
"widgets_values": ["ComfyUI"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"linearData": {
"inputs": [],
"outputs": ["9"]
}
},
"version": 0.4
}

View File

@@ -34,6 +34,10 @@ export class AppModeHelper {
public readonly outputPlaceholder: Locator
/** The linear-mode widget list container (visible in app mode). */
public readonly linearWidgets: Locator
/** The validation warning shown above the app mode run button. */
public readonly validationWarning: Locator
/** The action that opens graph mode errors from the validation warning. */
public readonly viewErrorsInGraphButton: Locator
/** The PrimeVue Popover for the image picker (renders with role="dialog"). */
public readonly imagePickerPopover: Locator
/** The Run button in the app mode footer. */
@@ -92,13 +96,19 @@ export class AppModeHelper {
this.outputPlaceholder = this.page.getByTestId(
TestIds.builder.outputPlaceholder
)
this.linearWidgets = this.page.getByTestId('linear-widgets')
this.linearWidgets = this.page.getByTestId(TestIds.linear.widgetContainer)
this.validationWarning = this.page.getByTestId(
TestIds.linear.validationWarning
)
this.viewErrorsInGraphButton = this.validationWarning.getByTestId(
TestIds.linear.viewErrorsInGraph
)
this.imagePickerPopover = this.page
.getByRole('dialog')
.filter({ has: this.page.getByRole('button', { name: 'All' }) })
.first()
this.runButton = this.page
.getByTestId('linear-run-button')
.getByTestId(TestIds.linear.runButton)
.getByRole('button', { name: /run/i })
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
this.emptyWorkflowText = this.page.getByTestId(

View File

@@ -172,6 +172,9 @@ export const TestIds = {
mobileNavigation: 'linear-mobile-navigation',
mobileWorkflows: 'linear-mobile-workflows',
outputInfo: 'linear-output-info',
runButton: 'linear-run-button',
validationWarning: 'linear-validation-warning',
viewErrorsInGraph: 'linear-view-errors',
widgetContainer: 'linear-widgets'
},
builder: {

View File

@@ -0,0 +1,106 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { NodeError, PromptResponse } from '@/schemas/apiSchema'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { enableErrorsOverlay } from '@e2e/fixtures/helpers/ErrorsTabHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const SAVE_IMAGE_NODE_ID = '9'
function buildSaveImageRequiredInputError(): NodeError {
return {
class_type: 'SaveImage',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing: images',
details: '',
extra_info: { input_name: 'images' }
}
]
}
}
test.describe(
'App mode validation warning',
{ tag: ['@ui', '@workflow'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await enableErrorsOverlay(comfyPage)
await comfyPage.workflow.loadWorkflow('linear-validation-warning')
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
})
test('opens graph errors from the app mode validation warning', async ({
comfyPage
}) => {
await expect(comfyPage.appMode.validationWarning).toBeHidden()
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
const appModeOverlay = comfyPage.appMode.centerPanel.getByTestId(
TestIds.dialogs.errorOverlay
)
await expect(appModeOverlay).toBeHidden()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.validationWarning).toContainText(
/Required input missing/i
)
await expect(comfyPage.appMode.viewErrorsInGraphButton).toBeVisible()
await comfyPage.appMode.viewErrorsInGraphButton.click()
await expect(comfyPage.appMode.linearWidgets).toBeHidden()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
).toBeVisible()
})
test('keeps the app mode run button enabled when the warning is visible', async ({
comfyPage
}) => {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[SAVE_IMAGE_NODE_ID]: buildSaveImageRequiredInputError()
})
await comfyPage.appMode.runButton.click()
await expect(comfyPage.appMode.validationWarning).toBeVisible()
await expect(comfyPage.appMode.runButton).toBeEnabled()
let promptQueued = false
const mockResponse: PromptResponse = {
prompt_id: 'test-id',
node_errors: {},
error: ''
}
await comfyPage.page.route(
'**/api/prompt',
async (route) => {
promptQueued = true
await route.fulfill({
status: 200,
body: JSON.stringify(mockResponse)
})
},
{ times: 1 }
)
await comfyPage.appMode.runButton.click()
await expect.poll(() => promptQueued).toBe(true)
})
}
)

View File

@@ -1,5 +1,6 @@
import { expect } from '@playwright/test'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
@@ -15,9 +16,10 @@ test.describe('Graph', { tag: ['@smoke', '@canvas'] }, () => {
await comfyPage.workflow.loadWorkflow('inputs/input_order_swap')
await expect
.poll(() =>
comfyPage.page.evaluate(() => {
return window.app!.graph!.links.get(1)?.target_slot
})
comfyPage.page.evaluate(
(linkId) => window.app!.graph!.links.get(linkId)?.target_slot,
toLinkId(1)
)
)
.toBe(1)
})

View File

@@ -3,6 +3,7 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Displays linear controls when app mode active', async ({
@@ -16,7 +17,9 @@ test.describe('Linear Mode', { tag: '@ui' }, () => {
test('Run button visible in linear mode', async ({ comfyPage }) => {
await comfyPage.appMode.enterAppModeWithInputs([])
await expect(comfyPage.page.getByTestId('linear-run-button')).toBeVisible()
await expect(
comfyPage.page.getByTestId(TestIds.linear.runButton)
).toBeVisible()
})
test('Workflow info section visible', async ({ comfyPage }) => {

View File

@@ -1,79 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('runWhenGlobalIdle', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('falls back to a timeout when idle callbacks are unavailable', async () => {
vi.useFakeTimers()
vi.stubGlobal('requestIdleCallback', undefined)
vi.stubGlobal('cancelIdleCallback', undefined)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
const disposable = runWhenGlobalIdle(runner)
await vi.runAllTimersAsync()
expect(runner).toHaveBeenCalledOnce()
const deadline = runner.mock.calls[0][0]
expect(deadline.didTimeout).toBe(true)
expect(deadline.timeRemaining()).toBeGreaterThanOrEqual(0)
disposable.dispose()
disposable.dispose()
})
it('cancels fallback idle work before it runs', async () => {
vi.useFakeTimers()
vi.stubGlobal('requestIdleCallback', undefined)
vi.stubGlobal('cancelIdleCallback', undefined)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
runWhenGlobalIdle(runner).dispose()
await vi.runAllTimersAsync()
expect(runner).not.toHaveBeenCalled()
})
it('uses native idle callbacks when available', async () => {
const requestIdleCallback = vi.fn(() => 42)
const cancelIdleCallback = vi.fn()
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
vi.stubGlobal('cancelIdleCallback', cancelIdleCallback)
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
const disposable = runWhenGlobalIdle(runner, 250)
expect(requestIdleCallback).toHaveBeenCalledWith(runner, { timeout: 250 })
disposable.dispose()
disposable.dispose()
expect(cancelIdleCallback).toHaveBeenCalledOnce()
expect(cancelIdleCallback).toHaveBeenCalledWith(42)
})
it('omits native idle timeout options when no timeout is supplied', async () => {
const requestIdleCallback = vi.fn(
(_cb: IdleRequestCallback, _options?: IdleRequestOptions) => 7
)
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
vi.stubGlobal('cancelIdleCallback', vi.fn())
const { runWhenGlobalIdle } = await import('./async')
const runner = vi.fn()
runWhenGlobalIdle(runner)
expect(requestIdleCallback).toHaveBeenCalledOnce()
expect(requestIdleCallback.mock.calls[0][0]).toBe(runner)
expect(requestIdleCallback.mock.calls[0][1]).toBeUndefined()
})
})

View File

@@ -4,7 +4,6 @@ import {
CREDITS_PER_USD,
COMFY_CREDIT_RATE_CENTS,
centsToCredits,
clampUsd,
creditsToCents,
creditsToUsd,
formatCredits,
@@ -44,23 +43,4 @@ describe('comfyCredits helpers', () => {
expect(formatCreditsFromUsd({ usd: 1, locale })).toBe('211.00')
expect(formatUsd({ value: 4.2, locale })).toBe('4.20')
})
test('recovers from incompatible fraction digit bounds', () => {
// {min:3,max:1} collapses to one fraction digit ('12.3'); the default {2,2}
// would yield '12.35', so this distinguishes recovery from options ignored.
expect(
formatCredits({
value: 12.345,
locale: 'en-US',
numberOptions: { minimumFractionDigits: 3, maximumFractionDigits: 1 }
})
).toBe('12.3')
})
test('clamps USD purchase values into the supported range', () => {
expect(clampUsd(Number.NaN)).toBe(0)
expect(clampUsd(-5)).toBe(1)
expect(clampUsd(42)).toBe(42)
expect(clampUsd(5000)).toBe(1000)
})
})

View File

@@ -37,7 +37,7 @@
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="error-overlay-see-errors"
@click="seeErrors"
@click="viewErrorsInGraph"
>
{{
appMode
@@ -67,31 +67,18 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
const { appMode = false } = defineProps<{ appMode?: boolean }>()
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const canvasStore = useCanvasStore()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const { isVisible, overlayMessage, overlayTitle } = useErrorOverlayState()
function dismiss() {
executionErrorStore.dismissErrorOverlay()
}
function seeErrors() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
</script>

View File

@@ -8,6 +8,7 @@ import type { ErrorRecoveryStrategy } from '@/composables/useErrorHandling'
import { st, t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { getCheckoutPlatformSource } from '@/platform/telemetry/utils/platformSource'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
@@ -136,7 +137,8 @@ export const useAuthActions = () => {
const response = await authStore.initiateCreditPurchase({
amount_micros: usdToMicros(amount),
currency: 'usd'
currency: 'usd',
platform_source: getCheckoutPlatformSource()
})
if (!response.checkout_url) {

View File

@@ -0,0 +1,105 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
const apiMock = vi.hoisted(() => ({
getSettings: vi.fn(),
storeSetting: vi.fn(),
storeSettings: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: apiMock
}))
const appMock = vi.hoisted(() => ({
ui: {
settings: {
dispatchChange: vi.fn()
}
},
rootGraph: {
events: new EventTarget(),
nodes: []
}
}))
vi.mock('@/scripts/app', () => ({
app: appMock
}))
function createSelectedCanvas() {
const graph = new LGraph()
const canvasElement = document.createElement('canvas')
canvasElement.width = 800
canvasElement.height = 600
canvasElement.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
const canvas = new LGraphCanvas(canvasElement, graph, {
skip_events: true,
skip_render: true
})
const node = new LGraphNode('Selected Node')
graph.add(node)
canvas.selectedItems.add(node)
node.selected = true
return { canvas, node }
}
describe('useViewErrorsInGraph', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
apiMock.getSettings.mockResolvedValue({})
apiMock.storeSetting.mockResolvedValue(undefined)
apiMock.storeSettings.mockResolvedValue(undefined)
})
it('opens graph errors and clears app-mode error UI state', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
const workflowStore = useWorkflowStore()
const { canvas, node } = createSelectedCanvas()
workflowStore.activeWorkflow = {
activeMode: 'app'
} as typeof workflowStore.activeWorkflow
canvasStore.canvas = canvas
canvasStore.selectedItems = [node]
executionErrorStore.showErrorOverlay()
useViewErrorsInGraph().viewErrorsInGraph()
expect(node.selected).toBe(false)
expect(canvasStore.linearMode).toBe(false)
expect(canvasStore.selectedItems).toEqual([])
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
it('opens graph errors when the canvas is not initialized', () => {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
canvasStore.canvas = null
executionErrorStore.showErrorOverlay()
expect(() => useViewErrorsInGraph().viewErrorsInGraph()).not.toThrow()
expect(rightSidePanelStore.activeTab).toBe('errors')
expect(rightSidePanelStore.isOpen).toBe(true)
expect(executionErrorStore.isErrorOverlayOpen).toBe(false)
})
})

View File

@@ -0,0 +1,22 @@
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
export function useViewErrorsInGraph() {
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const rightSidePanelStore = useRightSidePanelStore()
function viewErrorsInGraph() {
canvasStore.linearMode = false
if (canvasStore.canvas) {
canvasStore.canvas.deselectAll()
canvasStore.updateSelectedItems()
}
rightSidePanelStore.openPanel('errors')
executionErrorStore.dismissErrorOverlay()
}
return { viewErrorsInGraph }
}

View File

@@ -409,6 +409,7 @@ export interface PageViewMetadata {
}
export interface CheckoutAttributionMetadata {
platform_source?: PlatformSource
ga_client_id?: string
ga_session_id?: string
ga_session_number?: string
@@ -424,6 +425,8 @@ export interface CheckoutAttributionMetadata {
wbraid?: string
}
export type PlatformSource = 'cloud' | 'desktop_cloud' | 'desktop_local'
export interface SubscriptionMetadata {
current_tier?: string
reason?: SubscriptionDialogReason

View File

@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const distribution = vi.hoisted(() => ({
isCloud: false,
isDesktop: false
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return distribution.isCloud
},
get isDesktop() {
return distribution.isDesktop
}
}))
import { getCheckoutPlatformSource } from '../platformSource'
describe('getCheckoutPlatformSource', () => {
beforeEach(() => {
distribution.isCloud = false
distribution.isDesktop = false
window.localStorage.clear()
window.history.pushState({}, '', '/')
})
it('classifies cloud checkout launched from desktop', () => {
distribution.isCloud = true
window.history.pushState({}, '', '/pricing?utm_source=comfy.desktop')
expect(getCheckoutPlatformSource()).toBe('desktop_cloud')
})
it('classifies direct cloud checkout', () => {
distribution.isCloud = true
expect(getCheckoutPlatformSource()).toBe('cloud')
})
it('classifies cloud checkout from persisted desktop attribution', () => {
distribution.isCloud = true
window.localStorage.setItem(
'comfy_checkout_attribution',
JSON.stringify({ utm_source: 'comfy.desktop' })
)
expect(getCheckoutPlatformSource()).toBe('desktop_cloud')
})
it('prefers current URL attribution over stored attribution', () => {
distribution.isCloud = true
window.localStorage.setItem(
'comfy_checkout_attribution',
JSON.stringify({ utm_source: 'comfy.desktop' })
)
window.history.pushState({}, '', '/pricing?utm_source=direct')
expect(getCheckoutPlatformSource()).toBe('cloud')
})
it('classifies desktop local checkout', () => {
distribution.isDesktop = true
expect(getCheckoutPlatformSource()).toBe('desktop_local')
})
it('does not classify OSS browser checkout', () => {
expect(getCheckoutPlatformSource()).toBeUndefined()
})
})

View File

@@ -1,7 +1,15 @@
import { isPlainObject } from 'es-toolkit'
import { withTimeout } from 'es-toolkit/promise'
import type { CheckoutAttributionMetadata } from '../types'
import type { AttributionQueryKey } from './checkoutAttributionStorage'
import {
asNonEmptyString,
hasAttributionChanges,
persistAttribution,
readAttributionFromUrl,
readStoredAttribution
} from './checkoutAttributionStorage'
import { getCheckoutPlatformSource } from './platformSource'
type GaIdentity = {
client_id?: string
@@ -16,99 +24,10 @@ const GA_IDENTITY_FIELDS = [
] as const satisfies ReadonlyArray<GtagGetFieldName>
type GaIdentityField = GtagGetFieldName
const ATTRIBUTION_QUERY_KEYS = [
'im_ref',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'gclid',
'gbraid',
'wbraid'
] as const
type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
const GENERATE_CLICK_ID_TIMEOUT_MS = 300
const GET_GA_IDENTITY_TIMEOUT_MS = 300
const GET_REWARDFUL_REFERRAL_TIMEOUT_MS = 300
function readStoredAttribution(): Partial<Record<AttributionQueryKey, string>> {
if (typeof window === 'undefined') return {}
try {
const stored = localStorage.getItem(ATTRIBUTION_STORAGE_KEY)
if (!stored) return {}
const parsed: unknown = JSON.parse(stored)
if (!isPlainObject(parsed)) return {}
const result: Partial<Record<AttributionQueryKey, string>> = {}
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = asNonEmptyString(parsed[key])
if (value) {
result[key] = value
}
}
return result
} catch {
return {}
}
}
function persistAttribution(
payload: Partial<Record<AttributionQueryKey, string>>
): void {
if (typeof window === 'undefined') return
try {
localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload))
} catch {
return
}
}
function readAttributionFromUrl(
search: string
): Partial<Record<AttributionQueryKey, string>> {
const params = new URLSearchParams(search)
const result: Partial<Record<AttributionQueryKey, string>> = {}
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = params.get(key)
if (value) {
result[key] = value
}
}
return result
}
function hasAttributionChanges(
existing: Partial<Record<AttributionQueryKey, string>>,
incoming: Partial<Record<AttributionQueryKey, string>>
): boolean {
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = incoming[key]
if (value !== undefined && existing[key] !== value) {
return true
}
}
return false
}
function asNonEmptyString(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return typeof value === 'string' && value.length > 0 ? value : undefined
}
async function getGaIdentityField(
measurementId: string,
fieldName: GaIdentityField
@@ -245,6 +164,7 @@ export async function getCheckoutAttribution(): Promise<CheckoutAttributionMetad
return {
...attribution,
platform_source: getCheckoutPlatformSource(),
ga_client_id: gaIdentity?.client_id,
ga_session_id: gaIdentity?.session_id,
ga_session_number: gaIdentity?.session_number,

View File

@@ -0,0 +1,95 @@
import { isPlainObject } from 'es-toolkit'
const ATTRIBUTION_QUERY_KEYS = [
'im_ref',
'utm_source',
'utm_medium',
'utm_campaign',
'utm_term',
'utm_content',
'gclid',
'gbraid',
'wbraid'
] as const
export type AttributionQueryKey = (typeof ATTRIBUTION_QUERY_KEYS)[number]
const ATTRIBUTION_STORAGE_KEY = 'comfy_checkout_attribution'
export function asNonEmptyString(value: unknown): string | undefined {
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value)
}
return typeof value === 'string' && value.length > 0 ? value : undefined
}
export function readStoredAttribution(): Partial<
Record<AttributionQueryKey, string>
> {
if (typeof window === 'undefined') return {}
try {
const stored = localStorage.getItem(ATTRIBUTION_STORAGE_KEY)
if (!stored) return {}
const parsed: unknown = JSON.parse(stored)
if (!isPlainObject(parsed)) return {}
const result: Partial<Record<AttributionQueryKey, string>> = {}
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = asNonEmptyString(parsed[key])
if (value) {
result[key] = value
}
}
return result
} catch {
return {}
}
}
export function persistAttribution(
payload: Partial<Record<AttributionQueryKey, string>>
): void {
if (typeof window === 'undefined') return
try {
localStorage.setItem(ATTRIBUTION_STORAGE_KEY, JSON.stringify(payload))
} catch {
return
}
}
export function readAttributionFromUrl(
search: string
): Partial<Record<AttributionQueryKey, string>> {
const params = new URLSearchParams(search)
const result: Partial<Record<AttributionQueryKey, string>> = {}
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = params.get(key)
if (value) {
result[key] = value
}
}
return result
}
export function hasAttributionChanges(
existing: Partial<Record<AttributionQueryKey, string>>,
incoming: Partial<Record<AttributionQueryKey, string>>
): boolean {
for (const key of ATTRIBUTION_QUERY_KEYS) {
const value = incoming[key]
if (value !== undefined && existing[key] !== value) {
return true
}
}
return false
}

View File

@@ -0,0 +1,20 @@
import { isCloud, isDesktop } from '@/platform/distribution/types'
import type { PlatformSource } from '../types'
import {
readAttributionFromUrl,
readStoredAttribution
} from './checkoutAttributionStorage'
export function getCheckoutPlatformSource(): PlatformSource | undefined {
if (typeof window === 'undefined') return undefined
if (isCloud) {
const fromUrl = readAttributionFromUrl(window.location.search)
const source = fromUrl.utm_source ?? readStoredAttribution().utm_source
return source === 'comfy.desktop' ? 'desktop_cloud' : 'cloud'
}
return isDesktop ? 'desktop_local' : undefined
}

View File

@@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockAxiosInstance,
mockGetAuthHeaderOrThrow,
mockGetFirebaseAuthHeaderOrThrow
mockGetFirebaseAuthHeaderOrThrow,
mockGetCheckoutPlatformSource
} = vi.hoisted(() => ({
mockAxiosInstance: {
get: vi.fn(),
@@ -13,7 +14,8 @@ const {
interceptors: { response: { use: vi.fn() } }
},
mockGetAuthHeaderOrThrow: vi.fn(),
mockGetFirebaseAuthHeaderOrThrow: vi.fn()
mockGetFirebaseAuthHeaderOrThrow: vi.fn(),
mockGetCheckoutPlatformSource: vi.fn()
}))
vi.mock('axios', () => ({
@@ -47,6 +49,10 @@ vi.mock('@/stores/authStore', () => ({
})
}))
vi.mock('@/platform/telemetry/utils/platformSource', () => ({
getCheckoutPlatformSource: mockGetCheckoutPlatformSource
}))
import { workspaceApi } from './workspaceApi'
const AUTH_HEADER = { Authorization: 'Bearer test-token' }
@@ -56,6 +62,7 @@ describe('workspaceApi', () => {
vi.clearAllMocks()
mockGetAuthHeaderOrThrow.mockResolvedValue(AUTH_HEADER)
mockGetFirebaseAuthHeaderOrThrow.mockResolvedValue(AUTH_HEADER)
mockGetCheckoutPlatformSource.mockReturnValue('desktop_cloud')
})
describe('authentication', () => {
@@ -367,6 +374,7 @@ describe('workspaceApi', () => {
plan_slug: 'pro-monthly',
return_url: 'https://return.url',
cancel_url: 'https://cancel.url',
platform_source: 'desktop_cloud',
team_credit_stop_id: undefined,
billing_cycle: undefined
},
@@ -390,6 +398,7 @@ describe('workspaceApi', () => {
plan_slug: 'team_per_credit_annual',
return_url: undefined,
cancel_url: undefined,
platform_source: 'desktop_cloud',
team_credit_stop_id: 'team_700',
billing_cycle: 'yearly'
},
@@ -457,7 +466,11 @@ describe('workspaceApi', () => {
expect(mockAxiosInstance.post).toHaveBeenCalledWith(
'/api/billing/topup',
{ amount_cents: 1000, idempotency_key: 'key-3' },
{
amount_cents: 1000,
idempotency_key: 'key-3',
platform_source: 'desktop_cloud'
},
{ headers: AUTH_HEADER }
)
expect(result).toEqual(data)

View File

@@ -2,6 +2,8 @@ import axios from 'axios'
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
import type { SubscriptionTier } from '@/platform/cloud/subscription/constants/tierPricing'
import type { PlatformSource } from '@/platform/telemetry/types'
import { getCheckoutPlatformSource } from '@/platform/telemetry/utils/platformSource'
import type {
WorkspaceId,
WorkspaceInviteId
@@ -161,6 +163,7 @@ interface SubscribeRequest {
idempotency_key?: string
return_url?: string
cancel_url?: string
platform_source?: PlatformSource
/** Required for the per-credit Team plan; selects the slider stop. */
team_credit_stop_id?: string
billing_cycle?: SubscribeBillingCycle
@@ -283,6 +286,7 @@ export interface BillingBalanceResponse {
interface CreateTopupRequest {
amount_cents: number
idempotency_key?: string
platform_source?: PlatformSource
}
type TopupStatus = 'pending' | 'completed' | 'failed'
@@ -660,6 +664,7 @@ export const workspaceApi = {
plan_slug: planSlug,
return_url: options.returnUrl,
cancel_url: options.cancelUrl,
platform_source: getCheckoutPlatformSource(),
team_credit_stop_id: options.teamCreditStopId,
billing_cycle: options.billingCycle
} satisfies SubscribeRequest,
@@ -746,7 +751,8 @@ export const workspaceApi = {
api.apiURL('/billing/topup'),
{
amount_cents: amountCents,
idempotency_key: idempotencyKey
idempotency_key: idempotencyKey,
platform_source: getCheckoutPlatformSource()
} satisfies CreateTopupRequest,
{ headers }
)

View File

@@ -0,0 +1,208 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen, within } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeError } from '@/schemas/apiSchema'
import LinearControls from '@/renderer/extensions/linearMode/LinearControls.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { toNodeId } from '@/types/nodeId'
const billingMock = vi.hoisted(() => ({
isActiveSubscription: true
}))
const overlayMock = vi.hoisted(() => ({
overlayMessage: 'KSampler is missing a required input: model',
overlayTitle: 'Required input missing'
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: billingMock.isActiveSubscription
})
}))
vi.mock('@/components/error/useErrorOverlayState', () => ({
useErrorOverlayState: () => ({
overlayMessage: overlayMock.overlayMessage,
overlayTitle: overlayMock.overlayTitle
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
},
mobileNoWorkflow: 'No workflow',
runCount: 'Run count',
viewJob: 'View job'
},
menu: {
run: 'Run'
},
menuLabels: {
publish: 'Publish'
},
queue: {
jobAddedToQueue: 'Job added to queue',
jobQueueing: 'Queueing'
}
}
}
})
const nodeErrors: Record<string, NodeError> = {
'1': {
class_type: 'TestNode',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Missing input',
details: '',
extra_info: { input_name: 'prompt' }
}
]
}
}
function renderControls({
hasError = false,
isActiveSubscription = true,
mobile = false
}: {
hasError?: boolean
isActiveSubscription?: boolean
mobile?: boolean
} = {}) {
billingMock.isActiveSubscription = isActiveSubscription
const pinia = createTestingPinia({
createSpy: vi.fn,
stubActions: false
})
setActivePinia(pinia)
useAppModeStore().selectedOutputs = [toNodeId(1)]
if (hasError) {
useExecutionErrorStore().lastNodeErrors = nodeErrors
}
const toastTarget = document.createElement('div')
return render(LinearControls, {
props: { mobile, toastTo: toastTarget },
global: {
plugins: [pinia, i18n],
stubs: {
AppModeWidgetList: true,
Loader: true,
PartnerNodesList: true,
Popover: {
template: '<div><slot name="button" /><slot /></div>'
},
ScrubableNumberInput: true,
SubscribeToRunButton: true
}
}
})
}
describe('LinearControls', () => {
beforeEach(() => {
vi.clearAllMocks()
billingMock.isActiveSubscription = true
overlayMock.overlayMessage = 'KSampler is missing a required input: model'
overlayMock.overlayTitle = 'Required input missing'
})
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])('shows a workflow error warning in $label controls', ({ mobile }) => {
renderControls({ hasError: true, mobile })
const warning = screen.getByRole('status')
expect(
within(warning).getByText('Required input missing')
).toBeInTheDocument()
expect(
within(warning).getByText('KSampler is missing a required input: model')
).toBeInTheDocument()
expect(
within(warning).getByRole('button', { name: 'Show errors in graph' })
).toBeInTheDocument()
expect(within(warning).queryByLabelText('Close')).not.toBeInTheDocument()
const runButton = screen.getByRole('button', { name: 'Run' })
expect(runButton).toHaveAttribute(
'aria-describedby',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
const description = screen.getByTestId(
'linear-validation-warning-description'
)
expect(description).toHaveAttribute(
'id',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
expect(description).toHaveTextContent('Required input missing')
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
})
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'does not show the workflow error warning in $label controls without graph errors',
({ mobile }) => {
renderControls({ mobile })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Show errors in graph' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'
)
}
)
it.for([
{ label: 'desktop', mobile: false },
{ label: 'mobile', mobile: true }
])(
'does not show the workflow error warning in $label controls without an active subscription',
({ mobile }) => {
renderControls({
hasError: true,
isActiveSubscription: false,
mobile
})
expect(screen.queryByRole('status')).not.toBeInTheDocument()
}
)
it('does not show the warning when the error copy is empty', () => {
overlayMock.overlayMessage = ''
renderControls({ hasError: true })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Run' })).not.toHaveAttribute(
'aria-describedby'
)
})
})

View File

@@ -1,10 +1,11 @@
<script setup lang="ts">
import { useTimeout } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, useTemplateRef } from 'vue'
import { computed, ref, toValue, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import Loader from '@/components/loader/Loader.vue'
import ScrubableNumberInput from '@/components/common/ScrubableNumberInput.vue'
import Popover from '@/components/ui/Popover.vue'
@@ -14,11 +15,15 @@ import SubscribeToRunButton from '@/platform/cloud/subscription/components/Subsc
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
import PartnerNodesList from '@/renderer/extensions/linearMode/PartnerNodesList.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const { batchCount } = storeToRefs(useQueueSettingsStore())
@@ -28,6 +33,8 @@ const workflowStore = useWorkflowStore()
const { isBuilderMode } = useAppMode()
const appModeStore = useAppModeStore()
const { hasOutputs } = storeToRefs(appModeStore)
const { hasAnyError } = storeToRefs(useExecutionErrorStore())
const { overlayMessage } = useErrorOverlayState()
const { toastTo, mobile } = defineProps<{
toastTo?: string | HTMLElement
@@ -43,6 +50,13 @@ const { ready: jobToastTimeout, start: resetJobToastTimeout } = useTimeout(
{ controls: true, immediate: false }
)
const widgetListRef = useTemplateRef('widgetListRef')
const linearRunButtonTestId = 'linear-run-button'
const showRunErrorWarning = computed(
() =>
hasAnyError.value &&
toValue(isActiveSubscription) &&
toValue(overlayMessage).trim().length > 0
)
//TODO: refactor out of this file.
//code length is small, but changes should propagate
@@ -134,9 +148,10 @@ function handleDragDrop() {
<PartnerNodesList v-if="!mobile" />
<section
v-if="mobile"
data-testid="linear-run-button"
:data-testid="linearRunButtonTestId"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="mt-4 w-full"
@@ -166,18 +181,24 @@ function handleDragDrop() {
variant="primary"
class="grow"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</div>
</section>
<section
v-else
data-testid="linear-run-button"
:data-testid="linearRunButtonTestId"
class="border-t border-node-component-border p-4 pb-6"
>
<LinearRunErrorWarning v-if="showRunErrorWarning" />
<div
class="m-1 mb-2 text-node-component-slot-text"
v-text="t('linearMode.runCount')"
@@ -198,9 +219,14 @@ function handleDragDrop() {
variant="primary"
class="mt-4 w-full text-sm"
size="lg"
:aria-describedby="
showRunErrorWarning
? LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
: undefined
"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
<i aria-hidden="true" class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</section>

View File

@@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createI18n } from 'vue-i18n'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LinearRunErrorWarning from '@/renderer/extensions/linearMode/LinearRunErrorWarning.vue'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
const mocks = vi.hoisted(() => ({
overlayMessage: 'KSampler is missing a required input: model',
overlayTitle: 'Required input missing',
viewErrorsInGraph: vi.fn()
}))
vi.mock('@/components/error/useErrorOverlayState', () => ({
useErrorOverlayState: () => ({
overlayMessage: mocks.overlayMessage,
overlayTitle: mocks.overlayTitle
})
}))
vi.mock('@/composables/useViewErrorsInGraph', () => ({
useViewErrorsInGraph: () => ({
viewErrorsInGraph: mocks.viewErrorsInGraph
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
linearMode: {
error: {
goto: 'Show errors in graph'
}
}
}
}
})
function renderWarning() {
const user = userEvent.setup()
const result = render(LinearRunErrorWarning, {
global: { plugins: [i18n] }
})
return { ...result, user }
}
describe('LinearRunErrorWarning', () => {
beforeEach(() => {
mocks.viewErrorsInGraph.mockReset()
})
it('shows the current error overlay title and message without a close action', () => {
renderWarning()
const warning = screen.getByRole('status')
expect(warning).toHaveTextContent('Required input missing')
expect(warning).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(screen.getByText('Required input missing')).toHaveAttribute(
'title',
'Required input missing'
)
const description = screen.getByTestId(
'linear-validation-warning-description'
)
expect(description).toHaveAttribute(
'id',
LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID
)
expect(description).toHaveTextContent('Required input missing')
expect(description).toHaveTextContent(
'KSampler is missing a required input: model'
)
expect(description).not.toHaveTextContent('Show errors in graph')
expect(screen.queryByLabelText('Close')).not.toBeInTheDocument()
})
it('opens graph errors when the action is clicked', async () => {
const { user } = renderWarning()
await user.click(
screen.getByRole('button', { name: 'Show errors in graph' })
)
expect(mocks.viewErrorsInGraph).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useErrorOverlayState } from '@/components/error/useErrorOverlayState'
import { useViewErrorsInGraph } from '@/composables/useViewErrorsInGraph'
import { LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID } from '@/renderer/extensions/linearMode/linearRunErrorWarningIds'
const { t } = useI18n()
const { viewErrorsInGraph } = useViewErrorsInGraph()
const { overlayMessage, overlayTitle } = useErrorOverlayState()
</script>
<template>
<div
role="status"
data-testid="linear-validation-warning"
class="mb-3 flex w-full flex-col gap-2 overflow-hidden rounded-lg border border-l-4 border-border-default border-l-destructive-background bg-base-background p-3 shadow-interface transition-colors duration-200 ease-in-out"
>
<div
:id="LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID"
data-testid="linear-validation-warning-description"
class="flex flex-col gap-2"
>
<div class="flex w-full items-start gap-2">
<i
aria-hidden="true"
class="mt-0.5 icon-[lucide--circle-x] size-4 shrink-0 text-destructive-background"
/>
<span
class="min-w-0 flex-1 truncate text-sm text-base-foreground"
:title="overlayTitle"
>
{{ overlayTitle }}
</span>
</div>
<div
class="flex w-full items-start gap-2"
data-testid="linear-validation-warning-message"
>
<span class="size-4 shrink-0" aria-hidden="true" />
<p
class="m-0 line-clamp-3 min-w-0 flex-1 text-sm/snug wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ overlayMessage }}
</p>
</div>
</div>
<div class="flex w-full items-center justify-end pt-2">
<Button
variant="secondary"
size="unset"
class="min-h-8 rounded-lg px-3 py-2 text-xs font-normal"
data-testid="linear-view-errors"
@click="viewErrorsInGraph"
>
{{ t('linearMode.error.goto') }}
</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,2 @@
export const LINEAR_RUN_ERROR_WARNING_DESCRIPTION_ID =
'linear-run-error-warning'

View File

@@ -30,6 +30,7 @@ import {
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import type { PlatformSource } from '@/platform/telemetry/types'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
@@ -40,7 +41,9 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
type CreditPurchaseResponse =
operations['InitiateCreditPurchase']['responses']['201']['content']['application/json']
type CreditPurchasePayload =
operations['InitiateCreditPurchase']['requestBody']['content']['application/json']
operations['InitiateCreditPurchase']['requestBody']['content']['application/json'] & {
platform_source?: PlatformSource
}
type CreateCustomerResponse =
operations['createCustomer']['responses']['201']['content']['application/json']

View File

@@ -1,147 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { FuseSearchable } from '@/utils/fuseUtil'
import { FuseFilter, FuseSearch } from '@/utils/fuseUtil'
interface SearchItem extends Partial<FuseSearchable> {
name: string
}
interface FilterItem {
options: string[]
}
const makeSearch = <T>(data: T[] = []) =>
new FuseSearch<T>(data, {
fuseOptions: {
keys: ['name'],
includeScore: true,
threshold: 0.6,
shouldSort: false
},
advancedScoring: true
})
describe('FuseSearch', () => {
it('assigns stable ranking tiers for exact, prefix, word, substring, and multi-part matches', () => {
const search = new FuseSearch<string>([], {})
const cases = [
{ query: 'load image', item: 'load image', tier: 0 },
{ query: 'load', item: 'Load Image', tier: 1 },
{ query: 'image', item: 'LoadImage', tier: 2 },
{ query: 'cast', item: 'broadcast', tier: 3 },
{ query: 'batch latent', item: 'LatentBatch', tier: 4 },
{ query: 'ten bat', item: 'LatentBatch', tier: 5 },
{ query: 'vae', item: 'KSampler', tier: 9 }
]
for (const { query, item, tier } of cases) {
expect(search.calcAuxSingle(query, item, 0)[0]).toBe(tier)
}
})
it('penalizes deprecated non-exact matches without penalizing exact matches', () => {
const search = makeSearch<SearchItem>()
expect(
search.calcAuxScores('image', { name: 'Image Deprecated' }, 0)[0]
).toBe(6)
expect(
search.calcAuxScores('deprecated node', { name: 'Deprecated Node' }, 0)[0]
).toBe(0)
})
it('lets searchable entries post-process their auxiliary scores', () => {
const search = makeSearch<SearchItem>()
const entry: SearchItem = {
name: 'Image Loader',
postProcessSearchScores: (scores) => [scores[0] + 2, ...scores.slice(1)]
}
expect(search.calcAuxScores('image', entry, 0)[0]).toBe(3)
})
it('sorts advanced search results by auxiliary ranking instead of Fuse order', () => {
const exact = { name: 'Image' }
const prefix = { name: 'Image Loader' }
const camelCaseWord = { name: 'LoadImage' }
const substring = { name: 'PreimageNode' }
const deprecated = { name: 'Image Deprecated' }
const search = makeSearch([
substring,
deprecated,
camelCaseWord,
prefix,
exact
])
expect(search.search('image')).toEqual([
exact,
prefix,
camelCaseWord,
substring,
deprecated
])
})
it('returns data in original order for an empty query without calling Fuse', () => {
const data = [{ name: 'B' }, { name: 'A' }]
const search = makeSearch(data)
const fuseSearchSpy = vi.spyOn(search.fuse, 'search')
expect(search.search('')).toEqual(data)
expect(fuseSearchSpy).not.toHaveBeenCalled()
})
it('compares auxiliary scores by the first differing value and then length', () => {
const search = new FuseSearch<string>([], {})
expect(
[
[1, 4],
[1, 2],
[0, 99]
].sort(search.compareAux)
).toEqual([
[0, 99],
[1, 2],
[1, 4]
])
expect(
[
[1, 2, 0],
[1, 2]
].sort(search.compareAux)
).toEqual([
[1, 2],
[1, 2, 0]
])
})
})
describe('FuseFilter', () => {
it('matches single values, comma-separated values, and wildcard fallbacks', () => {
const imageItem = { options: ['IMAGE', 'LATENT'] }
const modelItem = { options: ['MODEL'] }
const filter = new FuseFilter<FilterItem, string>([imageItem, modelItem], {
id: 'type',
name: 'Type',
invokeSequence: 't',
getItemOptions: (item) => item.options
})
expect(filter.getAllNodeOptions([imageItem, modelItem, imageItem])).toEqual(
['IMAGE', 'LATENT', 'MODEL']
)
expect(filter.matches(imageItem, 'IMAGE')).toBe(true)
expect(filter.matches(imageItem, 'MODEL')).toBe(false)
expect(filter.matches(imageItem, 'MODEL,IMAGE')).toBe(true)
expect(filter.matches(modelItem, '*', { wildcard: '*' })).toBe(true)
expect(filter.matches(imageItem, 'MODEL', { wildcard: 'IMAGE' })).toBe(true)
expect(filter.matches(modelItem, 'MODEL', { wildcard: 'IMAGE' })).toBe(
false
)
})
})

View File

@@ -1,55 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createGridStyle } from '@/utils/gridUtil'
describe('createGridStyle', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('uses auto-fill columns by default', () => {
expect(createGridStyle()).toEqual({
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(15rem, 1fr))',
padding: '0',
gap: '1rem'
})
})
it('uses fixed columns when provided', () => {
expect(
createGridStyle({
columns: 3,
padding: '8px',
gap: '4px'
})
).toEqual({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
padding: '8px',
gap: '4px'
})
})
it('warns and clamps invalid fixed columns', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
expect(createGridStyle({ columns: -1 }).gridTemplateColumns).toBe(
'repeat(1, 1fr)'
)
expect(warn).toHaveBeenCalledWith(
'createGridStyle: columns must be >= 1, defaulting to 1'
)
})
it('warns for columns: 0 but falls through to auto-fill (falsy zero)', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
expect(createGridStyle({ columns: 0 }).gridTemplateColumns).toBe(
'repeat(auto-fill, minmax(15rem, 1fr))'
)
expect(warn).toHaveBeenCalledWith(
'createGridStyle: columns must be >= 1, defaulting to 1'
)
})
})

View File

@@ -1,39 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { whileMouseDown } from '@/utils/mouseDownUtil'
describe('whileMouseDown', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('runs until the element receives mouseup', () => {
const element = document.createElement('button')
const callback = vi.fn()
whileMouseDown(element, callback, 10)
vi.advanceTimersByTime(25)
element.dispatchEvent(new MouseEvent('mouseup'))
vi.advanceTimersByTime(30)
expect(callback.mock.calls).toEqual([[0], [1]])
})
it('uses the event target and stops on document mouseup', () => {
const element = document.createElement('button')
const event = new MouseEvent('mousedown')
Object.defineProperty(event, 'target', { value: element })
const callback = vi.fn()
whileMouseDown(event, callback, 5)
vi.advanceTimersByTime(12)
document.dispatchEvent(new MouseEvent('mouseup'))
vi.advanceTimersByTime(20)
expect(callback.mock.calls).toEqual([[0], [1]])
})
})

View File

@@ -1,52 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const options = {
emptyLabel: 'Empty Node',
untitledLabel: 'Untitled Node',
st: vi.fn((key: string, fallback: string) => `${key}:${fallback}`)
}
describe('resolveNodeDisplayName', () => {
beforeEach(() => {
options.st.mockClear()
})
it('uses the empty label when no node is available', () => {
expect(resolveNodeDisplayName(null, options)).toBe('Empty Node')
expect(resolveNodeDisplayName(undefined, options)).toBe('Empty Node')
expect(options.st).not.toHaveBeenCalled()
})
it('prefers a trimmed explicit title', () => {
expect(
resolveNodeDisplayName(
{ title: ' KSampler ', type: 'Ignored' },
options
)
).toBe('KSampler')
expect(options.st).not.toHaveBeenCalled()
})
it('translates the node type when the title is empty', () => {
const result = resolveNodeDisplayName(
{ title: '', type: 'CLIP Text Encode' },
options
)
const expectedKey = `nodeDefs.${normalizeI18nKey('CLIP Text Encode')}.display_name`
expect(options.st).toHaveBeenCalledWith(expectedKey, 'CLIP Text Encode')
expect(result).toBe(`${expectedKey}:CLIP Text Encode`)
})
it('falls back to the untitled label when title and type are empty', () => {
const expectedKey = `nodeDefs.${normalizeI18nKey('Untitled Node')}.display_name`
expect(resolveNodeDisplayName({ title: '', type: '' }, options)).toBe(
`${expectedKey}:Untitled Node`
)
expect(resolveNodeDisplayName({}, options)).toBe(
`${expectedKey}:Untitled Node`
)
})
})

View File

@@ -1,48 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
createSharedObjectUrl,
releaseSharedObjectUrl,
retainSharedObjectUrl
} from './objectUrlUtil'
describe('objectUrlUtil', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('retains and releases shared blob URLs by reference count', () => {
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL')
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test')
const url = createSharedObjectUrl(new Blob(['data']))
retainSharedObjectUrl(url)
releaseSharedObjectUrl(url)
expect(revokeObjectURL).not.toHaveBeenCalled()
releaseSharedObjectUrl(url)
expect(revokeObjectURL).toHaveBeenCalledWith(url)
})
it('ignores missing and non-blob URLs', () => {
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL')
retainSharedObjectUrl(undefined)
retainSharedObjectUrl('https://example.com/image.png')
releaseSharedObjectUrl(undefined)
releaseSharedObjectUrl('https://example.com/image.png')
expect(revokeObjectURL).not.toHaveBeenCalled()
})
it('revokes unknown blob URLs once', () => {
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL')
releaseSharedObjectUrl('blob:unknown')
expect(revokeObjectURL).toHaveBeenCalledOnce()
expect(revokeObjectURL).toHaveBeenCalledWith('blob:unknown')
})
})

View File

@@ -1,307 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import type { JobState } from '@/types/queue'
import type { BuildJobDisplayCtx } from '@/utils/queueDisplay'
import { buildJobDisplay, iconForJobState } from '@/utils/queueDisplay'
type QueueDisplayTask = Parameters<typeof buildJobDisplay>[0]
type PreviewOutput = NonNullable<QueueDisplayTask['previewOutput']>
function createJob(
status: JobListItem['status'],
overrides: Partial<JobListItem> = {}
): JobListItem {
return {
id: 'job-123456',
status,
create_time: 1_710_000_000_000,
priority: 12,
...overrides
}
}
function createTask(
options: {
job?: Partial<JobListItem>
jobId?: string
createTime?: number | undefined
executionTime?: number
executionTimeInSeconds?: number
previewOutput?: PreviewOutput
} = {}
): QueueDisplayTask {
const {
job,
jobId = 'job-123456',
executionTime,
executionTimeInSeconds,
previewOutput
} = options
const createTime = Object.hasOwn(options, 'createTime')
? options.createTime
: 1_710_000_000_000
return {
job: createJob(job?.status ?? 'pending', job),
jobId,
createTime,
executionTime,
executionTimeInSeconds,
previewOutput
} as QueueDisplayTask
}
function createCtx(
overrides: Partial<BuildJobDisplayCtx> = {}
): BuildJobDisplayCtx {
return {
t: (key, values) => {
const entries = Object.entries(values ?? {})
if (!entries.length) return key
return `${key}(${entries
.map(([name, value]) => `${name}=${String(value)}`)
.join(',')})`
},
locale: 'en-US',
formatClockTimeFn: (ts, locale) => `${locale}:${ts}`,
isActive: false,
...overrides
}
}
describe('iconForJobState', () => {
it.for<[JobState, string]>([
['pending', 'icon-[lucide--loader-circle]'],
['initialization', 'icon-[lucide--server-crash]'],
['running', 'icon-[lucide--zap]'],
['completed', 'icon-[lucide--check-check]'],
['failed', 'icon-[lucide--alert-circle]']
])('maps %s to its icon', ([state, icon]) => {
expect(iconForJobState(state)).toBe(icon)
})
it('uses a neutral icon for unrecognized states', () => {
expect(iconForJobState('archived' as JobState)).toBe(
'icon-[lucide--circle]'
)
})
})
describe('buildJobDisplay', () => {
it('shows the added hint for pending jobs when requested', () => {
expect(
buildJobDisplay(
createTask(),
'pending',
createCtx({ showAddedHint: true })
)
).toEqual({
iconName: 'icon-[lucide--check]',
primary: 'queue.jobAddedToQueue',
secondary: 'en-US:1710000000000',
showClear: true
})
})
it('shows queued time for pending and initializing jobs', () => {
expect(buildJobDisplay(createTask(), 'pending', createCtx())).toMatchObject(
{
iconName: 'icon-[lucide--loader-circle]',
primary: 'queue.inQueue',
secondary: 'en-US:1710000000000',
showClear: true
}
)
expect(
buildJobDisplay(createTask(), 'initialization', createCtx())
).toMatchObject({
iconName: 'icon-[lucide--server-crash]',
primary: 'queue.initializingAlmostReady',
secondary: 'en-US:1710000000000',
showClear: true
})
})
it('formats active running progress from the injected context', () => {
expect(
buildJobDisplay(
createTask({ job: { status: 'in_progress' } }),
'running',
createCtx({
isActive: true,
totalPercent: 42.7,
currentNodePercent: -10,
currentNodeName: 'KSampler'
})
)
).toEqual({
iconName: 'icon-[lucide--zap]',
primary: 'sideToolbar.queueProgressOverlay.total(percent=43%)',
secondary:
'KSampler sideToolbar.queueProgressOverlay.colonPercent(percent=0%)',
showClear: true
})
})
it('omits current node progress when the active job has no node name', () => {
expect(
buildJobDisplay(
createTask({ job: { status: 'in_progress' } }),
'running',
createCtx({
isActive: true,
totalPercent: 101,
currentNodePercent: 50
})
)
).toMatchObject({
primary: 'sideToolbar.queueProgressOverlay.total(percent=100%)',
secondary: ''
})
})
it('uses a compact running label when the job is not active', () => {
expect(
buildJobDisplay(
createTask({ job: { status: 'in_progress' } }),
'running',
createCtx()
)
).toEqual({
iconName: 'icon-[lucide--zap]',
primary: 'g.running',
secondary: '',
showClear: true
})
})
it('shows local completed jobs as the preview filename', () => {
expect(
buildJobDisplay(
createTask({
job: {
status: 'completed'
},
executionTimeInSeconds: 3.51,
previewOutput: {
filename: 'preview.png',
isImage: true,
url: '/api/view?filename=preview.png&type=output&subfolder='
} as PreviewOutput
}),
'completed',
createCtx()
)
).toEqual({
iconName: 'icon-[lucide--check-check]',
iconImageUrl: '/api/view?filename=preview.png&type=output&subfolder=',
primary: 'preview.png',
secondary: '3.51s',
showClear: false
})
})
it('shows cloud completed jobs as elapsed time', () => {
expect(
buildJobDisplay(
createTask({
job: {
status: 'completed'
},
executionTime: 64_000,
executionTimeInSeconds: 64
}),
'completed',
createCtx({ isCloud: true })
)
).toMatchObject({
iconName: 'icon-[lucide--check-check]',
primary: 'queue.completedIn(duration=1m 4s)',
secondary: '64.00s',
showClear: false
})
})
it('falls back to job title for completed jobs without a preview filename', () => {
expect(
buildJobDisplay(
createTask({
job: {
status: 'completed',
priority: 42
}
}),
'completed',
createCtx()
)
).toMatchObject({
iconName: 'icon-[lucide--check-check]',
primary: 'g.job #42',
secondary: '',
showClear: false
})
})
it('builds completed fallback titles from the job id', () => {
expect(
buildJobDisplay(
createTask({
jobId: 'abcdef-123',
job: { status: 'completed', priority: undefined }
}),
'completed',
createCtx()
).primary
).toBe('g.job abcdef')
})
it('uses the generic completed fallback title when ids are empty', () => {
expect(
buildJobDisplay(
createTask({
jobId: '',
job: { status: 'completed', id: '', priority: undefined }
}),
'completed',
createCtx()
).primary
).toBe('g.job')
})
it('uses an empty queued timestamp when create time is unavailable', () => {
expect(
buildJobDisplay(
createTask({ createTime: undefined }),
'pending',
createCtx()
).secondary
).toBe('')
})
it('shows failed jobs as clearable failures', () => {
expect(buildJobDisplay(createTask(), 'failed', createCtx())).toEqual({
iconName: 'icon-[lucide--alert-circle]',
primary: 'g.failed',
secondary: 'g.failed',
showClear: true
})
})
it('falls back to a neutral clearable display for unrecognized states', () => {
expect(
buildJobDisplay(
createTask({ jobId: 'abcdef-123' }),
'archived' as JobState,
createCtx()
)
).toEqual({
iconName: 'icon-[lucide--circle]',
primary: 'g.job #12',
secondary: '',
showClear: true
})
})
})

View File

@@ -1,67 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createRafBatch } from '@/utils/rafBatch'
describe('createRafBatch', () => {
const callbacks = new Map<number, FrameRequestCallback>()
const cancelAnimationFrame = vi.fn()
beforeEach(() => {
callbacks.clear()
cancelAnimationFrame.mockClear()
let nextId = 0
vi.stubGlobal(
'requestAnimationFrame',
vi.fn((callback: FrameRequestCallback) => {
const id = ++nextId
callbacks.set(id, callback)
return id
})
)
vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('coalesces scheduled work into one animation frame', () => {
const run = vi.fn()
const batch = createRafBatch(run)
batch.schedule()
batch.schedule()
expect(requestAnimationFrame).toHaveBeenCalledOnce()
expect(batch.isScheduled()).toBe(true)
callbacks.get(1)?.(0)
expect(run).toHaveBeenCalledOnce()
expect(batch.isScheduled()).toBe(false)
})
it('cancels and flushes scheduled work', () => {
const run = vi.fn()
const batch = createRafBatch(run)
batch.cancel()
batch.flush()
expect(cancelAnimationFrame).not.toHaveBeenCalled()
expect(run).not.toHaveBeenCalled()
batch.schedule()
batch.cancel()
expect(cancelAnimationFrame).toHaveBeenCalledWith(1)
expect(batch.isScheduled()).toBe(false)
batch.schedule()
batch.flush()
expect(cancelAnimationFrame).toHaveBeenCalledWith(2)
expect(run).toHaveBeenCalledOnce()
expect(batch.isScheduled()).toBe(false)
})
})

View File

@@ -1,14 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
isAbortError,
isNonNullish,
isResultItemType,
isSlotObject,
isSubgraph,
isSubgraphIoNode
} from '@/utils/typeGuardUtil'
import { isSubgraphIoNode } from '@/utils/typeGuardUtil'
type NodeConstructor = { comfyClass?: string }
@@ -17,40 +10,6 @@ function createMockNode(nodeConstructor?: NodeConstructor): LGraphNode {
}
describe('typeGuardUtil', () => {
describe('isAbortError', () => {
it('matches AbortError DOMExceptions only', () => {
expect(isAbortError(new DOMException('cancelled', 'AbortError'))).toBe(
true
)
expect(isAbortError(new DOMException('failed', 'NetworkError'))).toBe(
false
)
expect(isAbortError({ name: 'AbortError' })).toBe(false)
})
})
describe('isSubgraph', () => {
it('matches non-root graphs only', () => {
const subgraph = {
isRootGraph: false
} as Parameters<typeof isSubgraph>[0]
const rootGraph = {
isRootGraph: true
} as Parameters<typeof isSubgraph>[0]
expect(isSubgraph(subgraph)).toBe(true)
expect(isSubgraph(rootGraph)).toBe(false)
expect(isSubgraph(null)).toBe(false)
})
})
describe('isNonNullish', () => {
it('filters nullish values without dropping falsy data', () => {
const values = [0, '', null, undefined, false, 'ok']
expect(values.filter(isNonNullish)).toEqual([0, '', false, 'ok'])
})
})
describe('isSubgraphIoNode', () => {
it('should identify SubgraphInputNode as IO node', () => {
const node = createMockNode({ comfyClass: 'SubgraphInputNode' })
@@ -82,23 +41,4 @@ describe('typeGuardUtil', () => {
expect(isSubgraphIoNode(node)).toBe(false)
})
})
describe('isSlotObject', () => {
it('requires the slot shape fields', () => {
expect(
isSlotObject({ name: 'image', type: 'IMAGE', boundingRect: [] })
).toBe(true)
expect(isSlotObject(null)).toBe(false)
expect(isSlotObject('image')).toBe(false)
expect(isSlotObject({ name: 'image', type: 'IMAGE' })).toBe(false)
})
})
describe('isResultItemType', () => {
it('recognizes backend result buckets', () => {
expect(['input', 'output', 'temp'].every(isResultItemType)).toBe(true)
expect(isResultItemType('cache')).toBe(false)
expect(isResultItemType(undefined)).toBe(false)
})
})
})

View File

@@ -9,7 +9,6 @@ import { computed, useTemplateRef } from 'vue'
import AppBuilder from '@/components/builder/AppBuilder.vue'
import AppModeToolbar from '@/components/appMode/AppModeToolbar.vue'
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
import ErrorOverlay from '@/components/error/ErrorOverlay.vue'
import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
@@ -165,7 +164,6 @@ function dragDrop(e: DragEvent) {
</div>
<div ref="bottomLeftRef" class="absolute bottom-7 left-4 z-20" />
<div ref="bottomRightRef" class="absolute right-4 bottom-7 z-20" />
<div class="absolute top-4 right-4 z-20"><ErrorOverlay app-mode /></div>
</SplitterPanel>
<SplitterPanel
v-if="hasRightPanel"