Compare commits

..

24 Commits

Author SHA1 Message Date
pythongosssss
df54ef9894 refactor: simplify generating-card image reveal, bound extra cards
- Replace the parent-side decode gate (decoded-src map, ready/decoding sets, liveness pruning) with a per-card image fade
- GeneratingCard derives its own src and fades it in on load; GeneratingScreen just filters cards that have content to show
- Latent-preview swaps stay flash-free via the browser keeping the prior frame until the newer src decodes — no manual bookkeeping
- Cap generatingExtraCards on insert instead of slicing at read time, so a long run no longer retains every non-selected output in memory
- Add an interactive GeneratingScreen story (add / remove / reset) to exercise the fan entrance, reflow, eviction and exit animations
2026-07-02 07:10:20 -07:00
pythongosssss
7526bc2b12 feat: add animated outputs
- add generating screen fan of images with pop in animations
- track arrival order to display cards ordered in fan
2026-07-02 07:10:20 -07:00
pythongosssss
6455a49f58 test(appMode): drop ErrorOverlay assertion from LinearView
ErrorOverlay was removed from LinearView in #12557 (replaced by the app
mode run validation warning); update the merged test to match.

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

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

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

View File

@@ -15,7 +15,7 @@ const { categories } = defineProps<{
const activeSection = ref(categories[0]?.value ?? '')
const HEADER_OFFSET_PX = -144
const HEADER_OFFSET = -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_PX,
offset: HEADER_OFFSET,
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"
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-['']"
>
<slot />
</li>

View File

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

View File

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

View File

@@ -80,23 +80,19 @@ class HelpCenterHelper {
}
/**
* Intercept the Pylon support URL (and the legacy Zendesk one for safety)
* so it never actually loads in the new tab opened by the Contact Support
* command.
* Intercept the Zendesk support URL so it never actually loads in the
* new tab opened by the Contact Support command.
*/
async stubSupportPage(): Promise<void> {
for (const pattern of [
'https://comfy-org.portal.usepylon.com/**',
'https://support.comfy.org/**'
]) {
await this.page.context().route(pattern, (route: Route) =>
await this.page
.context()
.route('https://support.comfy.org/**', (route: Route) =>
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html></html>'
})
)
}
}
/**

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -103,14 +103,14 @@ test.describe('Settings', () => {
})
test.describe('Support', () => {
test('Should open Pylon question form with OSS environment tag', async ({
test('Should open external zendesk link with OSS tag', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
// Prevent loading the external page
await comfyPage.page
.context()
.route('https://comfy-org.portal.usepylon.com/**', (route) =>
.route('https://support.comfy.org/**', (route) =>
route.fulfill({ body: '<html></html>', contentType: 'text/html' })
)
@@ -119,9 +119,8 @@ test.describe('Support', () => {
const popup = await popupPromise
const url = new URL(popup.url())
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
expect(url.pathname).toBe('/forms/question')
expect(url.searchParams.get('comfy_environment')).toBe('oss')
expect(url.hostname).toBe('support.comfy.org')
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
await popup.close()
})

View File

@@ -122,15 +122,9 @@ test.describe('Error dialog', () => {
await popup.close()
})
test('Should open the Pylon bug-report form when "Help Fix This" is clicked', async ({
test('Should open contact support when "Help Fix This" is clicked', async ({
comfyPage
}) => {
await comfyPage.page
.context()
.route('https://comfy-org.portal.usepylon.com/**', (route) =>
route.fulfill({ body: '<html></html>', contentType: 'text/html' })
)
const errorDialog = await triggerConfigureError(comfyPage)
await expect(errorDialog).toBeVisible()
@@ -139,9 +133,7 @@ test.describe('Error dialog', () => {
)
const url = new URL(popup.url())
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
expect(url.pathname).toBe('/forms/report-a-bug')
expect(url.searchParams.get('product_area')).toBe('Workflow Error')
expect(url.hostname).toBe('support.comfy.org')
await popup.close()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -99,28 +99,26 @@ test.describe('Help Center', () => {
expect(url.pathname).toBe('/Comfy-Org/ComfyUI')
})
test('Help & Support item opens the Pylon question form tagged as OSS', async ({
test('Help & Support item opens the Zendesk support form with OSS tag', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('help').click()
)
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
expect(url.pathname).toBe('/forms/question')
expect(url.searchParams.get('comfy_environment')).toBe('oss')
expect(url.hostname).toBe('support.comfy.org')
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
})
test('Give Feedback item opens the Pylon question form in OSS mode', async ({
test('Give Feedback item opens Contact Support in OSS mode', async ({
helpCenter
}) => {
const url = await waitForPopup(helpCenter.page, () =>
helpCenter.menuItem('feedback').click()
)
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
expect(url.pathname).toBe('/forms/question')
expect(url.searchParams.get('comfy_environment')).toBe('oss')
expect(url.hostname).toBe('support.comfy.org')
expect(url.searchParams.get('tf_42243568391700')).toBe('oss')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,167 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import WorkflowActionsDropdown from './WorkflowActionsDropdown.vue'
const spies = vi.hoisted(() => ({
execute: vi.fn(),
trackUiButtonClicked: vi.fn(),
markAsSeen: vi.fn()
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({ displayLinearMode: false })
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: spies.execute, commands: [] })
}))
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({
getKeybindingByCommandId: () => ({ combo: { toString: () => 'Ctrl+L' } })
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackUiButtonClicked: spies.trackUiButtonClicked })
}))
vi.mock('@/composables/useWorkflowActionsMenu', async () => {
const { ref } = await import('vue')
return { useWorkflowActionsMenu: () => ({ menuItems: ref([]) }) }
})
vi.mock('@/composables/useNewMenuItemIndicator', async () => {
const { ref } = await import('vue')
return {
useNewMenuItemIndicator: () => ({
hasUnseenItems: ref(true),
markAsSeen: spies.markAsSeen
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { shortcutSuffix: ' ({shortcut})' },
breadcrumbsMenu: {
graph: 'Graph',
app: 'App',
enterNodeGraph: 'Enter node graph',
enterAppMode: 'Enter app mode',
workflowActions: 'Workflow actions'
}
}
}
})
function renderDropdown() {
const user = userEvent.setup()
const result = render(WorkflowActionsDropdown, {
props: { source: 'test' },
global: {
plugins: [i18n],
directives: { tooltip: {} },
stubs: {
// Emits update:open on mount so handleOpen's telemetry path is exercised.
DropdownMenuRoot: {
emits: ['update:open'],
mounted() {
this.$emit('update:open', true)
},
template: '<div><slot /></div>'
},
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
WorkflowActionsList: true,
Button: {
inheritAttrs: false,
template:
'<button v-bind="$attrs" @click="$emit(\'click\', $event)"><slot /></button>'
}
}
}
})
return { ...result, user }
}
describe('WorkflowActionsDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('keeps the active segment label in its accessible name alongside the actions label', () => {
renderDropdown()
// Graph is the active segment, so its name must contain the visible "Graph"
// label (label-in-name) while still matching the "Workflow actions" trigger.
const active = screen.getByRole('button', { name: /Workflow actions/ })
expect(active).toHaveAttribute('aria-label', 'Graph Workflow actions')
})
it('labels the inactive segment with its switch action only', () => {
renderDropdown()
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
expect(inactive).toHaveAttribute('aria-label', 'Enter app mode')
})
it('toggles the view mode when the inactive segment is clicked', async () => {
const { user } = renderDropdown()
await user.click(screen.getByRole('button', { name: 'Enter app mode' }))
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
metadata: { source: 'test' }
})
})
it('does not toggle the view mode when the active segment is clicked', async () => {
const { user } = renderDropdown()
await user.click(screen.getByRole('button', { name: /Workflow actions/ }))
expect(spies.execute).not.toHaveBeenCalled()
})
it('switches mode when the inactive segment is activated by keyboard', async () => {
const { user } = renderDropdown()
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
inactive.focus()
await user.keyboard('{Enter}')
// The keydown guard stops the event bubbling to the trigger, but native
// button activation still switches mode.
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
metadata: { source: 'test' }
})
})
it('does not switch mode when the active segment is activated by keyboard', async () => {
const { user } = renderDropdown()
const active = screen.getByRole('button', { name: /Workflow actions/ })
active.focus()
await user.keyboard('{Enter}')
expect(spies.execute).not.toHaveBeenCalled()
})
it('marks new items as seen and reports telemetry when the menu opens', () => {
renderDropdown()
expect(spies.markAsSeen).toHaveBeenCalled()
expect(spies.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'test',
element_group: 'workflow_actions'
})
})
})

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
DropdownMenuContent,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
} from 'reka-ui'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
@@ -17,25 +18,67 @@ import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCommandStore } from '@/stores/commandStore'
type ViewMode = 'graph' | 'app'
interface ViewModeSegment {
mode: ViewMode
icon: string
label: string
switchLabel: string
switchTooltip: string
active: boolean
}
const { source, align = 'start' } = defineProps<{
source: string
align?: 'start' | 'center' | 'end'
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const keybindingStore = useKeybindingStore()
const dropdownOpen = ref(false)
const canvasStore = useCanvasStore()
const { menuItems } = useWorkflowActionsMenu(
() => useCommandStore().execute('Comfy.RenameWorkflow'),
{ isRoot: true }
)
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
() => menuItems.value
)
const toggleShortcut = computed(() => {
const shortcut = keybindingStore
.getKeybindingByCommandId('Comfy.ToggleLinear')
?.combo.toString()
return shortcut ? t('g.shortcutSuffix', { shortcut }) : ''
})
const segments = computed<ViewModeSegment[]>(() => [
{
mode: 'graph',
icon: 'icon-[comfy--workflow]',
label: t('breadcrumbsMenu.graph'),
switchLabel: t('breadcrumbsMenu.enterNodeGraph'),
switchTooltip: t('breadcrumbsMenu.enterNodeGraph') + toggleShortcut.value,
active: !canvasStore.displayLinearMode
},
{
mode: 'app',
icon: 'icon-[lucide--panels-top-left]',
label: t('breadcrumbsMenu.app'),
switchLabel: t('breadcrumbsMenu.enterAppMode'),
switchTooltip: t('breadcrumbsMenu.enterAppMode') + toggleShortcut.value,
active: canvasStore.displayLinearMode
}
])
// Inactive segment first (left), active last (right). On mode switch the array
// reorders and TransitionGroup FLIP-animates the keyed nodes to their new spots.
const orderedSegments = computed(() =>
[...segments.value].sort((a, b) => Number(a.active) - Number(b.active))
)
function handleOpen(open: boolean) {
if (open) {
markAsSeen()
@@ -46,23 +89,32 @@ function handleOpen(open: boolean) {
}
}
function toggleModeTooltip() {
const label = canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
const shortcut = keybindingStore
.getKeybindingByCommandId('Comfy.ToggleLinear')
?.combo.toString()
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
}
function toggleLinearMode() {
function switchMode() {
dropdownOpen.value = false
void useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source }
})
}
// The container is the dropdown trigger, so an inactive segment must stop its
// pointer event from bubbling up and opening the menu instead of switching.
function onSegmentPointerDown(seg: ViewModeSegment, e: PointerEvent) {
if (!seg.active) e.stopPropagation()
}
// Keyboard mirror of the pointer guard: stop Enter/Space on an inactive segment
// from bubbling to the trigger. The button's native activation still fires
// onSegmentClick to switch mode, so the menu stays closed.
function onSegmentKeydown(seg: ViewModeSegment, e: KeyboardEvent) {
if (!seg.active && (e.key === 'Enter' || e.key === ' ')) e.stopPropagation()
}
function onSegmentClick(seg: ViewModeSegment, e: MouseEvent) {
if (seg.active) return
e.stopPropagation()
switchMode()
}
const tooltipPt = {
root: {
style: {
@@ -75,7 +127,7 @@ const tooltipPt = {
style: { whiteSpace: 'nowrap' }
},
arrow: {
class: '!left-[16px]'
style: { left: '16px' }
}
}
</script>
@@ -86,69 +138,81 @@ const tooltipPt = {
:modal="false"
@update:open="handleOpen"
>
<slot name="button" :has-unseen-items="hasUnseenItems">
<DropdownMenuTrigger as-child>
<div
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
data-testid="view-mode-toggle"
class="group pointer-events-auto relative inline-block rounded-lg bg-base-background p-1"
>
<Button
v-tooltip.bottom="{
value: toggleModeTooltip(),
showDelay: 300,
hideDelay: 300,
pt: tooltipPt
}"
:aria-label="
canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
"
variant="base"
class="m-1"
@pointerdown.stop
@click="toggleLinearMode"
<TransitionGroup
tag="div"
move-class="transition-[background-color,color,transform] duration-200"
class="flex items-center gap-1"
>
<i
class="size-4"
:class="
canvasStore.linearMode
? 'icon-[lucide--panels-top-left]'
: 'icon-[comfy--workflow]'
"
/>
</Button>
<DropdownMenuTrigger as-child>
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
v-for="seg in orderedSegments"
:key="seg.mode"
v-tooltip.bottom="{
value: seg.active
? t('breadcrumbsMenu.workflowActions')
: seg.switchTooltip,
showDelay: 300,
hideDelay: 300
hideDelay: 300,
pt: seg.active ? undefined : tooltipPt
}"
variant="secondary"
type="button"
variant="textonly"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
:aria-label="
seg.active
? `${seg.label} ${t('breadcrumbsMenu.workflowActions')}`
: seg.switchLabel
"
:class="
cn(
'relative flex h-8 items-center gap-0 rounded-md font-normal transition-[background-color,color,transform] duration-200',
seg.active
? 'bg-secondary-background pr-2 pl-2.5 text-base-foreground group-data-[state=open]:bg-secondary-background-hover group-data-[state=open]:shadow-interface hover:bg-secondary-background'
: 'w-8 justify-center bg-transparent text-muted-foreground hover:bg-secondary-background hover:text-base-foreground'
)
"
@pointerdown="onSegmentPointerDown(seg, $event)"
@keydown="onSegmentKeydown(seg, $event)"
@click="onSegmentClick(seg, $event)"
>
<span>{{
canvasStore.linearMode
? t('breadcrumbsMenu.app')
: t('breadcrumbsMenu.graph')
}}</span>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
/>
<i :class="cn('size-4 shrink-0', seg.icon)" aria-hidden="true" />
<span
v-if="hasUnseenItems"
:class="
cn(
'grid transition-[grid-template-columns,opacity] duration-200',
seg.active
? 'ml-1.5 grid-cols-[1fr] opacity-100'
: 'grid-cols-[0fr] opacity-0'
)
"
>
<span
class="flex min-w-0 items-center overflow-hidden text-sm leading-none whitespace-nowrap"
>
{{ seg.label }}
<i
class="ml-1 icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</span>
</span>
<span
v-if="seg.active && hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</DropdownMenuTrigger>
</TransitionGroup>
</div>
</slot>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:align
:side-offset="5"
:side-offset="8"
:collision-padding="10"
class="z-1000 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
>

View File

@@ -70,11 +70,10 @@ import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { SupportForm } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { useTelemetry } from '@/platform/telemetry'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { generateErrorReport } from '@/utils/errorReportUtil'
import type { ErrorReportData } from '@/utils/errorReportUtil'
@@ -116,18 +115,16 @@ const title = computed<string>(
() => error.nodeType ?? error.exceptionType ?? t('errorDialog.defaultTitle')
)
const { openSupport } = useSupportContext()
/**
* Open contact support flow from error dialog and track telemetry.
*/
const showContactSupport = () => {
const showContactSupport = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
openSupport(SupportForm.Bug, { productArea: 'Workflow Error' })
await useCommandStore().execute('Comfy.ContactSupport')
}
onMounted(async () => {

View File

@@ -45,15 +45,14 @@ import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import { SupportForm } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { useTelemetry } from '@/platform/telemetry'
import { useAuthStore } from '@/stores/authStore'
import { useCommandStore } from '@/stores/commandStore'
const { buildDocsUrl, docsPaths } = useExternalLink()
const authStore = useAuthStore()
const authActions = useAuthActions()
const { openSupport } = useSupportContext()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
@@ -71,13 +70,13 @@ const handleCreditsHistoryClick = async () => {
await authActions.accessBillingPortal()
}
const handleMessageSupport = () => {
const handleMessageSupport = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'credits_panel'
})
openSupport(SupportForm.Billing, { productArea: 'Credits' })
await commandStore.execute('Comfy.ContactSupport')
}
const handleFaqClick = () => {

View File

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

View File

@@ -49,13 +49,6 @@ vi.mock('@/stores/commandStore', () => ({
}))
}))
const mockOpenSupport = vi.fn()
vi.mock('@/platform/support/useSupportContext', () => ({
useSupportContext: vi.fn(() => ({
openSupport: mockOpenSupport
}))
}))
vi.mock('@/composables/useExternalLink', () => ({
useExternalLink: vi.fn(() => ({
staticUrls: {
@@ -360,7 +353,7 @@ describe('ErrorNodeCard.vue', () => {
openSpy.mockRestore()
})
it('opens the Pylon bug-report form when Get Help button is clicked', async () => {
it('executes ContactSupport command when Get Help button is clicked', async () => {
const { user } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
@@ -369,9 +362,7 @@ describe('ErrorNodeCard.vue', () => {
await user.click(screen.getByRole('button', { name: /Get Help/ }))
expect(mockOpenSupport).toHaveBeenCalledWith('report-a-bug', {
productArea: 'Workflow Error'
})
expect(mockExecuteCommand).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
expect.objectContaining({
resource_type: 'help_feedback',

View File

@@ -5,7 +5,7 @@ import { useErrorActions } from './useErrorActions'
const mocks = vi.hoisted(() => ({
trackUiButtonClicked: vi.fn(),
trackHelpResourceClicked: vi.fn(),
openSupport: vi.fn(),
execute: vi.fn(),
telemetry: null as {
trackUiButtonClicked: ReturnType<typeof vi.fn>
trackHelpResourceClicked: ReturnType<typeof vi.fn>
@@ -15,9 +15,9 @@ const mocks = vi.hoisted(() => ({
}
}))
vi.mock('@/platform/support/useSupportContext', () => ({
useSupportContext: () => ({
openSupport: mocks.openSupport
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mocks.execute
})
}))
@@ -41,7 +41,7 @@ describe('useErrorActions', () => {
}
mocks.trackUiButtonClicked.mockReset()
mocks.trackHelpResourceClicked.mockReset()
mocks.openSupport.mockReset()
mocks.execute.mockReset()
windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null as unknown as Window)
@@ -84,31 +84,36 @@ describe('useErrorActions', () => {
})
describe('contactSupport', () => {
it('tracks the help resource click and opens the Pylon bug-report form', () => {
it('tracks the help resource click and executes the contact support command', () => {
mocks.execute.mockReturnValue('executed')
const { contactSupport } = useErrorActions()
contactSupport()
const result = contactSupport()
expect(mocks.trackHelpResourceClicked).toHaveBeenCalledWith({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
expect(mocks.openSupport).toHaveBeenCalledWith('report-a-bug', {
productArea: 'Workflow Error'
})
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(result).toBe('executed')
})
it('still opens the support form when telemetry is unavailable', () => {
it('returns the execute promise when the command is async', async () => {
mocks.execute.mockResolvedValue('done')
const { contactSupport } = useErrorActions()
await expect(contactSupport()).resolves.toBe('done')
})
it('still executes the command when telemetry is unavailable', () => {
mocks.telemetry = null
const { contactSupport } = useErrorActions()
contactSupport()
void contactSupport()
expect(mocks.trackHelpResourceClicked).not.toHaveBeenCalled()
expect(mocks.openSupport).toHaveBeenCalledWith('report-a-bug', {
productArea: 'Workflow Error'
})
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
})
})

View File

@@ -1,11 +1,10 @@
import { useCommandStore } from '@/stores/commandStore'
import { useExternalLink } from '@/composables/useExternalLink'
import { SupportForm } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { useTelemetry } from '@/platform/telemetry'
export function useErrorActions() {
const telemetry = useTelemetry()
const { openSupport } = useSupportContext()
const commandStore = useCommandStore()
const { staticUrls } = useExternalLink()
function openGitHubIssues() {
@@ -22,7 +21,7 @@ export function useErrorActions() {
is_external: true,
source: 'error_dialog'
})
openSupport(SupportForm.Bug, { productArea: 'Workflow Error' })
return commandStore.execute('Comfy.ContactSupport')
}
function findOnGitHub(errorMessage: string) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import AppsSidebarTab from './AppsSidebarTab.vue'
const execute = vi.hoisted(() => vi.fn())
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { beta: 'Beta' },
linearMode: {
appModeToolbar: {
apps: 'Apps',
create: 'Create',
createApp: 'Create app',
appsEmptyMessage: 'No apps yet',
appsEmptyMessageAction: 'Create one to get started'
}
}
}
}
})
function renderTab({ hasResults = true }: { hasResults?: boolean } = {}) {
const user = userEvent.setup()
const result = render(AppsSidebarTab, {
global: {
plugins: [i18n],
stubs: {
BaseWorkflowsSidebarTab: {
template: `<div><slot name="header-actions" :has-results="${hasResults}" /><slot name="empty-state" /></div>`
},
Button: {
inheritAttrs: false,
template:
'<button v-bind="$attrs" @click="$emit(\'click\', $event)"><slot /></button>'
},
NoResultsPlaceholder: {
props: ['buttonLabel'],
emits: ['action'],
template:
'<button @click="$emit(\'action\')">{{ buttonLabel }}</button>'
}
}
}
})
return { ...result, user }
}
describe('AppsSidebarTab', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows the create action only when there are results', () => {
const { unmount } = renderTab({ hasResults: false })
expect(
screen.queryByRole('button', { name: 'Create' })
).not.toBeInTheDocument()
unmount()
renderTab({ hasResults: true })
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
})
it('runs the new-workflow command when the create action is clicked', async () => {
const { user } = renderTab({ hasResults: true })
await user.click(screen.getByRole('button', { name: 'Create' }))
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
})
it('runs the new-workflow command from the empty-state action', async () => {
const { user } = renderTab({ hasResults: false })
await user.click(screen.getByRole('button', { name: 'Create app' }))
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
})
})

View File

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

View File

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

View File

@@ -224,7 +224,7 @@ const handleOpenUserSettings = () => {
}
const handleOpenPlansAndPricing = () => {
subscriptionDialog.showPricingTable({ reason: 'avatar_menu_plans' })
subscriptionDialog.showPricingTable()
emit('close')
}
@@ -239,7 +239,8 @@ const handleOpenPlanAndCreditsSettings = () => {
}
const handleTopUp = () => {
useTelemetry()?.trackAddApiCreditButtonClicked({ source: 'avatar_menu' })
// Track purchase credits entry from avatar popover
useTelemetry()?.trackAddApiCreditButtonClicked()
dialogService.showTopUpCreditsDialog()
emit('close')
}
@@ -253,7 +254,7 @@ const handleOpenPartnerNodesInfo = () => {
}
const handleUpgradeToAddCredits = () => {
subscriptionDialog.showPricingTable({ reason: 'upgrade_to_add_credits' })
subscriptionDialog.showPricingTable()
emit('close')
}

View File

@@ -21,6 +21,6 @@ const { isFreeTier } = useBillingContext()
const subscriptionDialog = useSubscriptionDialog()
function handleClick() {
subscriptionDialog.showPricingTable({ reason: 'subscribe_now_button' })
subscriptionDialog.showPricingTable()
}
</script>

View File

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

View File

@@ -1,6 +1,5 @@
import type { ComputedRef, Ref } from 'vue'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
BillingStatus,
@@ -76,10 +75,9 @@ export interface BillingActions {
*/
requireActiveSubscription: () => Promise<void>
/**
* Shows the subscription dialog. Pass a reason so the paywall open and any
* downstream checkout stay attributed to the triggering product moment.
* Shows the subscription dialog.
*/
showSubscriptionDialog: (options?: SubscriptionDialogOptions) => void
showSubscriptionDialog: () => void
}
export interface BillingState {

View File

@@ -7,7 +7,6 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
PreviewSubscribeOptions,
SubscribeOptions
@@ -282,8 +281,8 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.requireActiveSubscription()
}
function showSubscriptionDialog(options?: SubscriptionDialogOptions) {
return activeContext.value.showSubscriptionDialog(options)
function showSubscriptionDialog() {
return activeContext.value.showSubscriptionDialog()
}
return {

View File

@@ -2,7 +2,6 @@ import { computed, ref } from 'vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
BillingStatus,
BillingSubscriptionStatus,
@@ -190,12 +189,12 @@ export function useLegacyBilling(): BillingState & BillingActions {
async function requireActiveSubscription(): Promise<void> {
await fetchStatus()
if (!isActiveSubscription.value) {
legacyShowSubscriptionDialog({ reason: 'subscription_required' })
legacyShowSubscriptionDialog()
}
}
function showSubscriptionDialog(options?: SubscriptionDialogOptions): void {
legacyShowSubscriptionDialog(options)
function showSubscriptionDialog(): void {
legacyShowSubscriptionDialog()
}
return {

View File

@@ -1,3 +1,4 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
@@ -22,8 +23,7 @@ import type { Point } from '@/lib/litegraph/src/litegraph'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { useSettingStore } from '@/platform/settings/settingStore'
import { SupportForm } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
import type { ExecutionTriggerSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -503,7 +503,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog({ reason: 'subscribe_to_run' })
showSubscriptionDialog()
return
}
@@ -526,7 +526,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog({ reason: 'subscribe_to_run' })
showSubscriptionDialog()
return
}
@@ -548,7 +548,7 @@ export function useCoreCommands(): ComfyCommand[] {
}) => {
trackRunButton(metadata)
if (!isActiveSubscription.value) {
showSubscriptionDialog({ reason: 'subscribe_to_run' })
showSubscriptionDialog()
return
}
@@ -864,7 +864,12 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Contact Support',
versionAdded: '1.17.8',
function: () => {
useSupportContext().openSupport(SupportForm.Question)
const { userEmail, resolvedUserInfo } = useCurrentUser()
const supportUrl = buildSupportUrl({
userEmail: userEmail.value,
userId: resolvedUserInfo.value?.id
})
window.open(supportUrl, '_blank', 'noopener,noreferrer')
}
},
{

View File

@@ -3690,13 +3690,13 @@
},
"linearMode": {
"linearMode": "App Mode",
"beta": "App mode in beta",
"buildAnApp": "Build an app",
"giveFeedback": "Give feedback",
"graphMode": "Graph Mode",
"dragAndDropImage": "Click to browse or drag an image",
"mobileControls": "Edit & Run",
"runCount": "Number of runs",
"generating": "Generating…",
"stopGeneration": "Stop generation",
"rerun": "Rerun",
"reuseParameters": "Reuse Parameters",
"downloadAll": "Download {count} assets from this run",
@@ -3705,7 +3705,6 @@
"emptyWorkflowExplanation": "Your workflow is empty. You need some nodes first to start building an app.",
"backToWorkflow": "Back to workflow",
"loadTemplate": "Load a template",
"cancelThisRun": "Cancel this run",
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"viewGraph": "View node graph",
@@ -3724,7 +3723,10 @@
"appBuilder": "App builder",
"apps": "Apps",
"appsEmptyMessage": "Saved apps will show up here.",
"appsEmptyMessageAction": "Click below to build your first app."
"appsEmptyMessageAction": "Click below to build your first app.",
"buildAnApp": "Build an app",
"create": "Create",
"createApp": "Create app"
},
"arrange": {
"noOutputs": "No outputs added yet",
@@ -3767,6 +3769,7 @@
"support": "contact our support",
"promptShow": "Show error report"
},
"feedbackLoadError": "Failed to load feedback form. Please try again later.",
"queue": {
"clickToClear": "Click to clear queue",
"clear": "Clear queue"

View File

@@ -25,6 +25,6 @@ function handleClose() {
}
function handleSubscribe() {
showSubscriptionDialog({ reason: 'upload_model_upgrade' })
showSubscriptionDialog()
}
</script>

View File

@@ -51,7 +51,7 @@
<p class="mb-5 text-center text-sm text-gray-600">
{{ $t('cloudOnboarding.authTimeout.helpText') }}
<a
:href="supportUrl"
href="https://support.comfy.org"
class="cursor-pointer text-blue-400 no-underline"
target="_blank"
rel="noopener noreferrer"
@@ -75,7 +75,6 @@ import { useRouter } from 'vue-router'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { SupportForm, buildSupportUrl } from '@/platform/support/config'
interface Props {
errorMessage?: string
@@ -87,10 +86,6 @@ const router = useRouter()
const { logout } = useAuthActions()
const showTechnicalDetails = ref(false)
const supportUrl = buildSupportUrl(SupportForm.Question, {
productArea: 'Cloud Onboarding'
})
const handleRestart = async () => {
await logout()
await router.replace({ name: 'cloud-login' })

View File

@@ -113,7 +113,7 @@
>
{{ t('cloudWaitlist_questionsText') }}
<a
:href="supportUrl"
href="https://support.comfy.org"
class="cursor-pointer text-azure-600 no-underline"
target="_blank"
rel="noopener noreferrer"
@@ -136,7 +136,6 @@ import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { usePostAuthRedirect } from '@/platform/cloud/onboarding/composables/usePostAuthRedirect'
import { SupportForm, buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
import type { SignUpData } from '@/schemas/signInSchema'
import { isInChina } from '@/utils/networkUtil'
@@ -163,10 +162,6 @@ const { onAuthSuccess } = usePostAuthRedirect({
defaultRedirect: () => ({ path: '/', query: route.query })
})
const supportUrl = buildSupportUrl(SupportForm.Question, {
productArea: 'Cloud Onboarding'
})
const navigateToLogin = async () => {
await router.push({ name: 'cloud-login', query: route.query })
}

View File

@@ -140,10 +140,7 @@ describe('CloudSubscriptionRedirectView', () => {
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
'creator',
'monthly',
{
openInNewTab: false,
paymentIntentSource: 'deep_link'
}
false
)
// Shows loading affordances
@@ -172,10 +169,7 @@ describe('CloudSubscriptionRedirectView', () => {
expect(mockPerformSubscriptionCheckout).toHaveBeenCalledWith(
'creator',
'monthly',
{
openInNewTab: false,
paymentIntentSource: 'deep_link'
}
false
)
})
@@ -186,8 +180,7 @@ describe('CloudSubscriptionRedirectView', () => {
expect(screen.getByText('Subscribe to Team Plan')).toBeInTheDocument()
expect(mockPerformTeamSubscriptionCheckout).toHaveBeenCalledWith(
'team_700',
'yearly',
{ paymentIntentSource: 'deep_link' }
'yearly'
)
// Team never goes through the personal checkout path
expect(mockPerformSubscriptionCheckout).not.toHaveBeenCalled()

View File

@@ -94,9 +94,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
return
}
isTeamCheckout.value = true
await performTeamSubscriptionCheckout(stopId, billingCycle, {
paymentIntentSource: 'deep_link'
})
await performTeamSubscriptionCheckout(stopId, billingCycle)
return
}
@@ -114,10 +112,7 @@ const runRedirect = wrapWithErrorHandlingAsync(async () => {
if (isActiveSubscription.value) {
await accessBillingPortal(undefined, false)
} else {
await performSubscriptionCheckout(tierKeyParam, billingCycle, {
openInNewTab: false,
paymentIntentSource: 'deep_link'
})
await performSubscriptionCheckout(tierKeyParam, billingCycle, false)
}
}, reportError)

View File

@@ -17,7 +17,7 @@
{{ t('auth.login.privacyLink') }}
</a>
<a
:href="supportUrl"
href="https://support.comfy.org"
class="cursor-pointer text-sm text-gray-600 no-underline"
target="_blank"
rel="noopener noreferrer"
@@ -30,11 +30,5 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { SupportForm, buildSupportUrl } from '@/platform/support/config'
const { t } = useI18n()
const supportUrl = buildSupportUrl(SupportForm.Question, {
productArea: 'Cloud Onboarding'
})
</script>

View File

@@ -351,12 +351,12 @@ const handleRefresh = wrapWithErrorHandlingAsync(async () => {
})
function handleAddCredits() {
telemetry?.trackAddApiCreditButtonClicked({ source: 'credits_panel' })
telemetry?.trackAddApiCreditButtonClicked()
void dialogService.showTopUpCreditsDialog()
}
function handleUpgradeToAddCredits() {
showPricingTable({ reason: 'upgrade_to_add_credits' })
showPricingTable()
}
async function handleWindowFocus() {

View File

@@ -5,8 +5,6 @@ import { render, screen } from '@testing-library/vue'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import FreeTierDialogContent from './FreeTierDialogContent.vue'
const mockRenewalDate = vi.hoisted(() => ({ value: null as string | null }))
@@ -17,7 +15,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
}))
}))
function renderComponent(props?: { reason?: PaymentIntentSource }) {
function renderComponent() {
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -25,7 +23,6 @@ function renderComponent(props?: { reason?: PaymentIntentSource }) {
})
return render(FreeTierDialogContent, {
props,
global: {
plugins: [i18n]
}
@@ -46,18 +43,4 @@ describe('FreeTierDialogContent', () => {
renderComponent()
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
it('keeps the generic copy for intent reasons outside the credits variants', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent({ reason: 'subscribe_to_run' })
expect(
screen.getByText('Your credits refresh on Jul 15, 2026.')
).toBeInTheDocument()
})
it('swaps to the out-of-credits copy without the refresh line', () => {
mockRenewalDate.value = '2026-07-15T10:00:00Z'
renderComponent({ reason: 'out_of_credits' })
expect(screen.queryByText(/credits refresh on/)).not.toBeInTheDocument()
})
})

View File

@@ -52,7 +52,7 @@
</p>
<p
v-if="!isCreditsBlockedVariant"
v-if="!reason || reason === 'subscription_required'"
class="m-0 text-sm text-text-secondary"
>
{{
@@ -65,7 +65,10 @@
</p>
<p
v-if="!isCreditsBlockedVariant && formattedRenewalDate"
v-if="
(!reason || reason === 'subscription_required') &&
formattedRenewalDate
"
class="m-0 text-sm text-text-secondary"
>
{{
@@ -85,7 +88,7 @@
@click="$emit('upgrade')"
>
{{
isCreditsBlockedVariant
reason === 'out_of_credits' || reason === 'top_up_blocked'
? $t('subscription.freeTier.upgradeCta')
: $t('subscription.freeTier.subscribeCta')
}}
@@ -100,12 +103,12 @@ import { computed } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'
const { reason } = defineProps<{
reason?: PaymentIntentSource
defineProps<{
reason?: SubscriptionDialogReason
}>()
defineEmits<{
@@ -126,10 +129,4 @@ const formattedRenewalDate = computed(() => {
})
const freeTierCredits = computed(() => getTierCredits('free'))
// Only these two variants replace the generic free-tier copy; any other
// intent reason (subscribe_to_run, deep_link, ...) keeps the default pitch.
const isCreditsBlockedVariant = computed(
() => reason === 'out_of_credits' || reason === 'top_up_blocked'
)
</script>

View File

@@ -261,7 +261,6 @@ describe('PricingTable', () => {
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
checkout_attempt_id: expect.any(String),
previous_tier: 'standard'
})
expect(mockAccessBillingPortal).toHaveBeenCalledWith('creator-yearly')
@@ -342,7 +341,6 @@ describe('PricingTable', () => {
expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
it('should use the latest userId value when it changes after mount', async () => {
@@ -368,7 +366,6 @@ describe('PricingTable', () => {
tier: 'creator',
cycle: 'yearly',
checkout_type: 'change',
checkout_attempt_id: expect.any(String),
previous_tier: 'standard'
})
})

View File

@@ -277,19 +277,13 @@ import type {
TierKey,
TierPricing
} from '@/platform/cloud/subscription/constants/tierPricing'
import {
recordPendingSubscriptionCheckoutAttempt,
withPendingCheckoutAttemptId
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { performSubscriptionCheckout } from '@/platform/cloud/subscription/utils/subscriptionCheckoutUtil'
import { isPlanDowngrade } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type {
CheckoutAttributionMetadata,
PaymentIntentSource
} from '@/platform/telemetry/types'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import { useAuthStore } from '@/stores/authStore'
type CheckoutTierKey = Exclude<TierKey, 'free' | 'founder'>
@@ -327,10 +321,6 @@ interface PricingTierConfig {
isPopular?: boolean
}
const { reason } = defineProps<{
reason?: PaymentIntentSource
}>()
const emit = defineEmits<{
chooseTeamWorkspace: []
}>()
@@ -473,17 +463,16 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
} as const
const previousPlan = currentPlanDescriptor.value
const checkoutAttribution = await getCheckoutAttributionForCloud()
const beginCheckoutMetadata = userId.value
? {
user_id: userId.value,
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change' as const,
...(reason ? { payment_intent_source: reason } : {}),
...checkoutAttribution,
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
}
: null
if (userId.value) {
telemetry?.trackBeginCheckout({
user_id: userId.value,
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
...checkoutAttribution,
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {})
})
}
// Pass the target tier to create a deep link to subscription update confirmation
const checkoutTier = getCheckoutTier(
targetPlan.tierKey,
@@ -498,39 +487,29 @@ const handleSubscribe = wrapWithErrorHandlingAsync(
if (downgrade) {
// TODO(COMFY-StripeProration): Remove once backend checkout creation mirrors portal proration ("change at billing end")
const didOpenPortal = await accessBillingPortal()
if (didOpenPortal && beginCheckoutMetadata) {
telemetry?.trackBeginCheckout(beginCheckoutMetadata)
}
await accessBillingPortal()
} else {
const didOpenPortal = await accessBillingPortal(checkoutTier)
if (!didOpenPortal) {
return
}
const pendingAttempt = recordPendingSubscriptionCheckoutAttempt({
recordPendingSubscriptionCheckoutAttempt({
tier: targetPlan.tierKey,
cycle: targetPlan.billingCycle,
checkout_type: 'change',
payment_intent_source: reason,
...(previousPlan ? { previous_tier: previousPlan.tierKey } : {}),
...(previousPlan
? { previous_cycle: previousPlan.billingCycle }
: {})
})
if (beginCheckoutMetadata) {
telemetry?.trackBeginCheckout(
withPendingCheckoutAttemptId(
beginCheckoutMetadata,
pendingAttempt
)
)
}
}
} else {
await performSubscriptionCheckout(tierKey, currentBillingCycle.value, {
paymentIntentSource: reason
})
await performSubscriptionCheckout(
tierKey,
currentBillingCycle.value,
true
)
}
} finally {
isLoading.value = false

View File

@@ -56,7 +56,7 @@ const handleSubscribe = () => {
current_tier: tier.value?.toLowerCase()
})
isAwaitingStripeSubscription.value = true
showSubscriptionDialog({ reason: 'subscribe_now_button' })
showSubscriptionDialog()
}
onBeforeUnmount(() => {

View File

@@ -54,6 +54,6 @@ function handleSubscribeToRun() {
trackRunButton({ subscribe_to_run: true })
}
showSubscriptionDialog({ reason: 'subscribe_to_run' })
showSubscriptionDialog()
}
</script>

View File

@@ -48,9 +48,7 @@
v-if="isActiveSubscription"
variant="primary"
class="rounded-lg px-4 py-2 text-sm font-normal text-text-primary"
@click="
showSubscriptionDialog({ reason: 'settings_billing_panel' })
"
@click="showSubscriptionDialog"
>
{{ $t('subscription.upgradePlan') }}
</Button>

View File

@@ -33,11 +33,7 @@
</i18n-t>
</div>
<PricingTable
:reason
class="flex-1"
@choose-team-workspace="handleChooseTeam"
/>
<PricingTable class="flex-1" @choose-team-workspace="handleChooseTeam" />
<!-- Contact and Enterprise Links -->
<div class="flex flex-col items-center gap-2">
@@ -159,14 +155,13 @@ import SubscribeButton from '@/platform/cloud/subscription/components/SubscribeB
import SubscriptionBenefits from '@/platform/cloud/subscription/components/SubscriptionBenefits.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { SupportForm } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { useTelemetry } from '@/platform/telemetry'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import { useCommandStore } from '@/stores/commandStore'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
const { onClose, reason, onChooseTeam } = defineProps<{
onClose: () => void
reason?: PaymentIntentSource
reason?: SubscriptionDialogReason
onChooseTeam?: () => void
}>()
@@ -189,7 +184,7 @@ const formattedMonthlyPrice = new Intl.NumberFormat(
maximumFractionDigits: 0
}
).format(MONTHLY_SUBSCRIPTION_PRICE)
const { openSupport } = useSupportContext()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
// Always show custom pricing table for cloud subscriptions
@@ -220,13 +215,13 @@ const handleClose = () => {
onClose()
}
const handleContactUs = () => {
const handleContactUs = async () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
openSupport(SupportForm.Billing, { productArea: 'Billing' })
await commandStore.execute('Comfy.ContactSupport')
}
const handleViewEnterprise = () => {

View File

@@ -24,9 +24,7 @@ export function useAccountPreconditionDialog() {
)
return
case 'subscription':
void dialogService.showSubscriptionRequiredDialog({
reason: 'subscription_required'
})
void dialogService.showSubscriptionRequiredDialog()
return
case 'credits':
void dialogService.showTopUpCreditsDialog({

View File

@@ -55,6 +55,12 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
})
}))
const mockTrackSubscription = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
describe('usePricingTableUrlLoader', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -90,6 +96,9 @@ describe('usePricingTableUrlLoader', () => {
reason: 'deep_link',
planMode: undefined
})
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
reason: 'deep_link'
})
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
})
@@ -141,6 +150,7 @@ describe('usePricingTableUrlLoader', () => {
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
it('denies, strips, and clears together when the user is not eligible', async () => {
@@ -151,6 +161,7 @@ describe('usePricingTableUrlLoader', () => {
await loadPricingTableFromUrl()
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({
query: { other: 'param' }
})
@@ -219,6 +230,7 @@ describe('usePricingTableUrlLoader', () => {
)
expect(mockShowPricingTable).not.toHaveBeenCalled()
expect(mockTrackSubscription).not.toHaveBeenCalled()
expect(mockRouterReplace).toHaveBeenCalledWith({ query: {} })
expect(preservedQueryMocks.clearPreservedQuery).toHaveBeenCalledWith(
'pricing'

View File

@@ -7,6 +7,7 @@ import {
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useTelemetry } from '@/platform/telemetry'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
@@ -61,6 +62,7 @@ export function usePricingTableUrlLoader() {
const planMode =
param === 'team' || param === 'personal' ? param : undefined
useTelemetry()?.trackSubscription('modal_opened', { reason: 'deep_link' })
subscriptionDialog.showPricingTable({ reason: 'deep_link', planMode })
}

View File

@@ -15,7 +15,7 @@ import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import { useDialogService } from '@/services/dialogService'
@@ -237,7 +237,14 @@ function useSubscriptionInternal() {
})
}, reportError)
const showSubscriptionDialog = (options?: SubscriptionDialogOptions) => {
const showSubscriptionDialog = (options?: {
reason?: SubscriptionDialogReason
}) => {
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: subscriptionTier.value?.toLowerCase(),
reason: options?.reason
})
void showSubscriptionRequiredDialog(options)
}
@@ -270,7 +277,7 @@ function useSubscriptionInternal() {
await fetchSubscriptionStatus()
if (!isSubscribedOrIsNotCloud.value) {
showSubscriptionDialog({ reason: 'subscription_required' })
showSubscriptionDialog()
}
}

View File

@@ -6,7 +6,7 @@ const mockBillingFetchBalance = vi.fn()
const mockAuthFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockOpenSupport = vi.fn()
const mockExecute = vi.fn()
const mockToastAdd = vi.fn()
vi.mock('@/platform/updates/common/toastStore', () => ({
@@ -32,42 +32,25 @@ vi.mock('@/services/dialogService', () => ({
})
}))
vi.mock('@/platform/support/useSupportContext', () => ({
useSupportContext: () => ({
openSupport: mockOpenSupport
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mockExecute
})
}))
// mockIsCloud drives both the `isCloud` build flag (which gates the telemetry
// call) and useTelemetry() (which returns null in OSS, a dispatcher in cloud).
const {
mockIsCloud,
mockTrackHelpResourceClicked,
mockTrackAddApiCreditButtonClicked
} = vi.hoisted(() => ({
// useTelemetry() returns null in OSS, a dispatcher in cloud — toggle via mockIsCloud.
const { mockIsCloud, mockTrackHelpResourceClicked } = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockTrackHelpResourceClicked: vi.fn(),
mockTrackAddApiCreditButtonClicked: vi.fn()
mockTrackHelpResourceClicked: vi.fn()
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () =>
mockIsCloud.value
? {
trackHelpResourceClicked: mockTrackHelpResourceClicked,
trackAddApiCreditButtonClicked: mockTrackAddApiCreditButtonClicked
}
? { trackHelpResourceClicked: mockTrackHelpResourceClicked }
: null
}))
vi.mock('@/platform/distribution/types', () => ({
isDesktop: false,
isNightly: false,
get isCloud() {
return mockIsCloud.value
}
}))
// Mock window.open
const mockOpen = vi.fn()
Object.defineProperty(window, 'open', {
@@ -86,31 +69,28 @@ describe('useSubscriptionActions', () => {
const { handleAddApiCredits } = useSubscriptionActions()
handleAddApiCredits()
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
expect(mockTrackAddApiCreditButtonClicked).toHaveBeenCalledWith({
source: 'settings_billing_panel'
})
})
})
describe('handleMessageSupport', () => {
it('opens the Pylon billing form and resets loading state', () => {
it('should execute support command and manage loading state', async () => {
const { handleMessageSupport, isLoadingSupport } =
useSubscriptionActions()
expect(isLoadingSupport.value).toBe(false)
handleMessageSupport()
const promise = handleMessageSupport()
expect(isLoadingSupport.value).toBe(true)
expect(mockOpenSupport).toHaveBeenCalledWith('billing-refund-issue', {
productArea: 'Billing'
})
await promise
expect(mockExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(isLoadingSupport.value).toBe(false)
})
it('tracks help-resource telemetry when messaging support in cloud', () => {
it('tracks help-resource telemetry when messaging support in cloud', async () => {
const { handleMessageSupport } = useSubscriptionActions()
handleMessageSupport()
await handleMessageSupport()
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith({
resource_type: 'help_feedback',
@@ -119,23 +99,21 @@ describe('useSubscriptionActions', () => {
})
})
it('does not fire telemetry when messaging support in OSS builds', () => {
it('does not fire telemetry when messaging support in OSS builds', async () => {
mockIsCloud.value = false
const { handleMessageSupport } = useSubscriptionActions()
handleMessageSupport()
await handleMessageSupport()
expect(mockTrackHelpResourceClicked).not.toHaveBeenCalled()
})
it('handles errors gracefully', () => {
mockOpenSupport.mockImplementationOnce(() => {
throw new Error('open failed')
})
it('should handle errors gracefully', async () => {
mockExecute.mockRejectedValueOnce(new Error('Command failed'))
const { handleMessageSupport, isLoadingSupport } =
useSubscriptionActions()
handleMessageSupport()
await handleMessageSupport()
expect(isLoadingSupport.value).toBe(false)
})
})

View File

@@ -1,18 +1,16 @@
import { onMounted, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { isCloud } from '@/platform/distribution/types'
import { SupportForm } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
/**
* Composable for handling subscription panel actions and loading states
*/
export function useSubscriptionActions() {
const dialogService = useDialogService()
const { openSupport } = useSupportContext()
const commandStore = useCommandStore()
const telemetry = useTelemetry()
const { fetchBalance, fetchStatus } = useBillingContext()
@@ -23,23 +21,18 @@ export function useSubscriptionActions() {
})
const handleAddApiCredits = () => {
telemetry?.trackAddApiCreditButtonClicked({
source: 'settings_billing_panel'
})
void dialogService.showTopUpCreditsDialog()
}
const handleMessageSupport = () => {
const handleMessageSupport = async () => {
try {
isLoadingSupport.value = true
if (isCloud) {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
}
openSupport(SupportForm.Billing, { productArea: 'Billing' })
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
await commandStore.execute('Comfy.ContactSupport')
} catch (error) {
console.error('[useSubscriptionActions] Error contacting support:', error)
} finally {

View File

@@ -5,10 +5,8 @@ import { useSubscriptionDialog } from './useSubscriptionDialog'
const mockCloseDialog = vi.fn()
const mockShowLayoutDialog = vi.fn()
const mockShowTeamWorkspacesDialog = vi.fn()
const mockTrackSubscription = vi.hoisted(() => vi.fn())
const mockIsInPersonalWorkspace = vi.hoisted(() => ({ value: true }))
const mockIsFreeTier = vi.hoisted(() => ({ value: false }))
const mockTier = vi.hoisted(() => ({ value: 'FREE' as string | null }))
const mockTeamWorkspacesEnabled = vi.hoisted(() => ({ value: false }))
const mockIsCloud = vi.hoisted(() => ({ value: true }))
const mockIsLegacyTeamPlan = vi.hoisted(() => ({ value: false }))
@@ -62,15 +60,10 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isFreeTier: mockIsFreeTier,
isLegacyTeamPlan: mockIsLegacyTeamPlan,
tier: mockTier
isLegacyTeamPlan: mockIsLegacyTeamPlan
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackSubscription: mockTrackSubscription })
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
@@ -87,7 +80,6 @@ describe('useSubscriptionDialog', () => {
mockIsCloud.value = true
mockIsInPersonalWorkspace.value = true
mockIsFreeTier.value = false
mockTier.value = 'FREE'
mockTeamWorkspacesEnabled.value = false
mockIsLegacyTeamPlan.value = false
mockCanManageSubscription.value = true
@@ -206,51 +198,6 @@ describe('useSubscriptionDialog', () => {
const props = mockShowLayoutDialog.mock.calls[0][0].props
expect(props.initialPlanMode).toBe('team')
})
it('tracks modal_opened with the caller reason and current tier', () => {
mockTier.value = 'STANDARD'
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'upgrade_to_add_credits' })
expect(mockTrackSubscription).toHaveBeenCalledWith('modal_opened', {
current_tier: 'standard',
reason: 'upgrade_to_add_credits'
})
})
it('tracks modal_opened on the workspace (unified) path too', () => {
mockTeamWorkspacesEnabled.value = true
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockTrackSubscription).toHaveBeenCalledWith(
'modal_opened',
expect.objectContaining({ reason: 'subscribe_to_run' })
)
})
it('does not track modal_opened for the inactive member dialog', () => {
mockTeamWorkspacesEnabled.value = true
mockIsInPersonalWorkspace.value = false
mockCanManageSubscription.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
it('does not track on non-cloud', () => {
mockIsCloud.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'subscribe_to_run' })
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
})
describe('show', () => {
@@ -288,20 +235,6 @@ describe('useSubscriptionDialog', () => {
expect.objectContaining({ key: 'subscription-required' })
)
})
it('tracks modal_opened with the reason for the free-tier dialog', () => {
mockIsFreeTier.value = true
mockIsInPersonalWorkspace.value = true
const { show } = useSubscriptionDialog()
show({ reason: 'out_of_credits' })
expect(mockTrackSubscription).toHaveBeenCalledTimes(1)
expect(mockTrackSubscription).toHaveBeenCalledWith(
'modal_opened',
expect.objectContaining({ reason: 'out_of_credits' })
)
})
})
describe('startTeamWorkspaceUpgradeFlow', () => {

View File

@@ -4,8 +4,6 @@ import { useDialogStore } from '@/stores/dialogStore'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
@@ -13,8 +11,14 @@ const DIALOG_KEY = 'subscription-required'
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
export interface SubscriptionDialogOptions {
reason?: PaymentIntentSource
export type SubscriptionDialogReason =
| 'subscription_required'
| 'out_of_credits'
| 'top_up_blocked'
| 'deep_link'
interface SubscriptionDialogOptions {
reason?: SubscriptionDialogReason
/**
* Forces the unified pricing dialog to open on a specific plan tab,
* overriding the workspace-derived default (e.g. an "Upgrade to Team" CTA
@@ -34,17 +38,6 @@ export const useSubscriptionDialog = () => {
dialogStore.closeDialog({ key: FREE_TIER_DIALOG_KEY })
}
// Fired here — the choke point every paywall/pricing dialog variant passes
// through — so both the legacy and workspace billing paths emit it.
function trackModalOpened(reason?: PaymentIntentSource) {
// Resolved lazily to avoid the useBillingContext import cycle (see below).
const { tier } = useBillingContext()
useTelemetry()?.trackSubscription('modal_opened', {
current_tier: tier.value?.toLowerCase(),
reason
})
}
function showPricingTable(options?: SubscriptionDialogOptions) {
if (!isCloud) return
@@ -78,8 +71,6 @@ export const useSubscriptionDialog = () => {
return
}
trackModalOpened(options?.reason)
// Shared dialog shell styling for both variants.
const dialogComponentProps = {
style: 'width: min(1328px, 95vw); max-height: 958px;',
@@ -176,8 +167,6 @@ export const useSubscriptionDialog = () => {
// (not at composable setup) to avoid the useBillingContext import cycle.
const { isFreeTier } = useBillingContext()
if (isFreeTier.value && workspaceStore.isInPersonalWorkspace) {
trackModalOpened(options?.reason)
const component = defineAsyncComponent(
() =>
import('@/platform/cloud/subscription/components/FreeTierDialogContent.vue')
@@ -247,7 +236,7 @@ export const useSubscriptionDialog = () => {
sessionStorage.removeItem(RESUME_PRICING_KEY)
if (!workspaceStore.isInPersonalWorkspace) {
showPricingTable({ reason: 'team_upgrade_resume' })
showPricingTable()
}
} catch {
// sessionStorage may be unavailable

View File

@@ -1,49 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
clearPendingSubscriptionCheckoutAttempt,
consumePendingSubscriptionCheckoutSuccess,
recordPendingSubscriptionCheckoutAttempt
} from './subscriptionCheckoutTracker'
const activeProStatus = {
is_active: true,
subscription_tier: 'PRO',
subscription_duration: 'MONTHLY'
} as const
describe('subscriptionCheckoutTracker', () => {
beforeEach(() => {
clearPendingSubscriptionCheckoutAttempt()
})
it('round-trips payment_intent_source from attempt to success metadata', () => {
recordPendingSubscriptionCheckoutAttempt({
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
})
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
expect(metadata).toMatchObject({
tier: 'pro',
checkout_type: 'new',
payment_intent_source: 'subscribe_to_run'
})
})
it('omits payment_intent_source when the attempt had none', () => {
recordPendingSubscriptionCheckoutAttempt({
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new'
})
const metadata = consumePendingSubscriptionCheckoutSuccess(activeProStatus)
expect(metadata).not.toBeNull()
expect(metadata).not.toHaveProperty('payment_intent_source')
})
})

View File

@@ -7,12 +7,7 @@ import type {
TierKey
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
import type {
BeginCheckoutMetadata,
PaymentIntentSource,
SubscriptionCheckoutType,
SubscriptionSuccessMetadata
} from '@/platform/telemetry/types'
import type { SubscriptionSuccessMetadata } from '@/platform/telemetry/types'
const PENDING_SUBSCRIPTION_CHECKOUT_MAX_AGE_MS = 6 * 60 * 60 * 1000
const VALID_TIER_KEYS = new Set<TierKey>([
@@ -28,6 +23,7 @@ export const PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY =
export const PENDING_SUBSCRIPTION_CHECKOUT_EVENT =
'comfy:subscription-checkout-attempt-changed'
type CheckoutType = 'new' | 'change'
type SubscriptionDuration = 'MONTHLY' | 'ANNUAL'
interface SubscriptionStatusSnapshot {
@@ -36,24 +32,22 @@ interface SubscriptionStatusSnapshot {
subscription_duration?: SubscriptionDuration | null
}
export interface PendingSubscriptionCheckoutAttempt {
interface PendingSubscriptionCheckoutAttempt {
attempt_id: string
started_at_ms: number
tier: TierKey
cycle: BillingCycle
checkout_type: SubscriptionCheckoutType
checkout_type: CheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
payment_intent_source?: PaymentIntentSource
}
interface PendingSubscriptionCheckoutAttemptInput {
interface RecordPendingSubscriptionCheckoutAttemptInput {
tier: TierKey
cycle: BillingCycle
checkout_type: SubscriptionCheckoutType
checkout_type: CheckoutType
previous_tier?: TierKey
previous_cycle?: BillingCycle
payment_intent_source?: PaymentIntentSource
}
const dispatchPendingCheckoutChangeEvent = () => {
@@ -174,9 +168,6 @@ const normalizeAttempt = (
...(candidate.previous_cycle === 'monthly' ||
candidate.previous_cycle === 'yearly'
? { previous_cycle: candidate.previous_cycle }
: {}),
...(typeof candidate.payment_intent_source === 'string'
? { payment_intent_source: candidate.payment_intent_source }
: {})
}
}
@@ -233,27 +224,20 @@ const getPendingSubscriptionCheckoutAttempt =
export const hasPendingSubscriptionCheckoutAttempt = (): boolean =>
getPendingSubscriptionCheckoutAttempt() !== null
export const createPendingSubscriptionCheckoutAttempt = (
input: PendingSubscriptionCheckoutAttemptInput
export const recordPendingSubscriptionCheckoutAttempt = (
input: RecordPendingSubscriptionCheckoutAttemptInput
): PendingSubscriptionCheckoutAttempt => {
return {
const storage = getStorage()
const attempt: PendingSubscriptionCheckoutAttempt = {
attempt_id: createAttemptId(),
started_at_ms: Date.now(),
tier: input.tier,
cycle: input.cycle,
checkout_type: input.checkout_type,
...(input.previous_tier ? { previous_tier: input.previous_tier } : {}),
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {}),
...(input.payment_intent_source
? { payment_intent_source: input.payment_intent_source }
: {})
...(input.previous_cycle ? { previous_cycle: input.previous_cycle } : {})
}
}
export const persistPendingSubscriptionCheckoutAttempt = (
attempt: PendingSubscriptionCheckoutAttempt
): PendingSubscriptionCheckoutAttempt => {
const storage = getStorage()
if (!storage) {
return attempt
}
@@ -271,21 +255,6 @@ export const persistPendingSubscriptionCheckoutAttempt = (
return attempt
}
export const recordPendingSubscriptionCheckoutAttempt = (
input: PendingSubscriptionCheckoutAttemptInput
): PendingSubscriptionCheckoutAttempt =>
persistPendingSubscriptionCheckoutAttempt(
createPendingSubscriptionCheckoutAttempt(input)
)
export const withPendingCheckoutAttemptId = (
metadata: BeginCheckoutMetadata,
attempt: PendingSubscriptionCheckoutAttempt
): BeginCheckoutMetadata => ({
...metadata,
checkout_attempt_id: attempt.attempt_id
})
const didAttemptSucceed = (
attempt: PendingSubscriptionCheckoutAttempt,
status: SubscriptionStatusSnapshot
@@ -318,9 +287,6 @@ export const consumePendingSubscriptionCheckoutSuccess = (
cycle: attempt.cycle,
checkout_type: attempt.checkout_type,
...(attempt.previous_tier ? { previous_tier: attempt.previous_tier } : {}),
...(attempt.payment_intent_source
? { payment_intent_source: attempt.payment_intent_source }
: {}),
value,
currency: 'USD',
ecommerce: {

View File

@@ -132,14 +132,13 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'yearly')
await performSubscriptionCheckout('pro', 'yearly', true)
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-123',
tier: 'pro',
cycle: 'yearly',
checkout_type: 'new',
checkout_attempt_id: expect.any(String),
ga_client_id: 'ga-client-id',
ga_session_id: 'ga-session-id',
ga_session_number: 'ga-session-number',
@@ -151,12 +150,6 @@ describe('performSubscriptionCheckout', () => {
gbraid: 'gbraid-456',
wbraid: 'wbraid-789'
})
const beginCheckoutMetadata =
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
JSON.parse(storedAttempt).attempt_id
)
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining(
'/customers/cloud-subscription-checkout/pro-yearly'
@@ -193,7 +186,7 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly')
await performSubscriptionCheckout('pro', 'monthly', true)
expect(warnSpy).toHaveBeenCalledWith(
'[SubscriptionCheckout] Failed to collect checkout attribution',
@@ -210,43 +203,11 @@ describe('performSubscriptionCheckout', () => {
user_id: 'user-123',
tier: 'pro',
cycle: 'monthly',
checkout_type: 'new',
checkout_attempt_id: expect.any(String)
checkout_type: 'new'
})
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('carries the payment intent source into begin_checkout and the pending attempt', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => window as unknown as Window)
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly', {
paymentIntentSource: 'out_of_credits'
})
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({ payment_intent_source: 'out_of_credits' })
)
const beginCheckoutMetadata =
mockTelemetry.trackBeginCheckout.mock.calls[0][0]
const [, storedAttempt] = mockLocalStorage.setItem.mock.calls[0]
const pendingAttempt = JSON.parse(storedAttempt)
expect(pendingAttempt).toMatchObject({
payment_intent_source: 'out_of_credits'
})
expect(beginCheckoutMetadata.checkout_attempt_id).toBe(
pendingAttempt.attempt_id
)
openSpy.mockRestore()
})
it('uses the latest userId when it changes after checkout starts', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi
@@ -261,7 +222,7 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly')
const checkoutPromise = performSubscriptionCheckout('pro', 'yearly', true)
mockUserId.value = 'user-late'
authHeader.resolve({ Authorization: 'Bearer test-token' })
@@ -274,14 +235,13 @@ describe('performSubscriptionCheckout', () => {
user_id: 'user-late',
tier: 'pro',
cycle: 'yearly',
checkout_type: 'new',
checkout_attempt_id: expect.any(String)
checkout_type: 'new'
})
)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
})
it('does not persist the pending attempt when the checkout popup is blocked', async () => {
it('does not persist a pending attempt when the checkout popup is blocked', async () => {
const checkoutUrl = 'https://checkout.stripe.com/test'
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
@@ -290,18 +250,11 @@ describe('performSubscriptionCheckout', () => {
json: async () => ({ checkout_url: checkoutUrl })
} as Response)
await performSubscriptionCheckout('pro', 'monthly')
await performSubscriptionCheckout('pro', 'monthly', true)
expect(openSpy).toHaveBeenCalledWith(checkoutUrl, '_blank')
const storedAttempt = window.localStorage.getItem(
PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY
)
expect(storedAttempt).toBeNull()
expect(mockLocalStorage.setItem).not.toHaveBeenCalled()
expect(mockTelemetry.trackBeginCheckout).toHaveBeenCalledWith(
expect.objectContaining({
checkout_attempt_id: expect.any(String)
})
)
expect(
window.localStorage.getItem(PENDING_SUBSCRIPTION_CHECKOUT_STORAGE_KEY)
).toBeNull()
})
})

View File

@@ -4,19 +4,12 @@ import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { fetchWithUnifiedRemint } from '@/platform/auth/unified/remintRetry'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import {
createPendingSubscriptionCheckoutAttempt,
persistPendingSubscriptionCheckoutAttempt,
withPendingCheckoutAttemptId
} from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type {
CheckoutAttributionMetadata,
PaymentIntentSource
} from '@/platform/telemetry/types'
import { AuthStoreError, useAuthStore } from '@/stores/authStore'
import type { CheckoutAttributionMetadata } from '@/platform/telemetry/types'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { recordPendingSubscriptionCheckoutAttempt } from '@/platform/cloud/subscription/utils/subscriptionCheckoutTracker'
import type { BillingCycle } from './subscriptionTierRank'
type CheckoutTier = TierKey | `${TierKey}-yearly`
@@ -38,11 +31,6 @@ const getCheckoutAttributionForCloud =
return getCheckoutAttribution()
}
interface PerformSubscriptionCheckoutOptions {
openInNewTab?: boolean
paymentIntentSource?: PaymentIntentSource
}
/**
* Core subscription checkout logic shared between PricingTable and
* SubscriptionRedirectView. Handles:
@@ -59,12 +47,10 @@ interface PerformSubscriptionCheckoutOptions {
export async function performSubscriptionCheckout(
tierKey: TierKey,
currentBillingCycle: BillingCycle,
options: PerformSubscriptionCheckoutOptions = {}
openInNewTab: boolean = true
): Promise<void> {
if (!isCloud) return
const { openInNewTab = true, paymentIntentSource } = options
const authStore = useAuthStore()
const { userId } = storeToRefs(authStore)
const telemetry = useTelemetry()
@@ -122,29 +108,14 @@ export async function performSubscriptionCheckout(
const data = await response.json()
if (data.checkout_url) {
const pendingAttempt = createPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
payment_intent_source: paymentIntentSource
})
if (userId.value) {
telemetry?.trackBeginCheckout(
withPendingCheckoutAttemptId(
{
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
...(paymentIntentSource
? { payment_intent_source: paymentIntentSource }
: {}),
...checkoutAttribution
},
pendingAttempt
)
)
telemetry?.trackBeginCheckout({
user_id: userId.value,
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new',
...checkoutAttribution
})
}
if (openInNewTab) {
@@ -152,9 +123,18 @@ export async function performSubscriptionCheckout(
if (!checkoutWindow) {
return
}
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
} else {
persistPendingSubscriptionCheckoutAttempt(pendingAttempt)
recordPendingSubscriptionCheckoutAttempt({
tier: tierKey,
cycle: currentBillingCycle,
checkout_type: 'new'
})
globalThis.location.href = data.checkout_url
}
}

View File

@@ -1,13 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, reactive } from 'vue'
const { mockIsCloud, mockSubscribe, mockTrackBeginCheckout, mockUserId } =
vi.hoisted(() => ({
mockIsCloud: { value: true },
mockSubscribe: vi.fn(),
mockTrackBeginCheckout: vi.fn(),
mockUserId: { value: 'user-1' as string | null }
}))
const { mockIsCloud, mockSubscribe } = vi.hoisted(() => ({
mockIsCloud: { value: true },
mockSubscribe: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -20,12 +16,6 @@ vi.mock('@/config/comfyApi', () => ({
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: { subscribe: mockSubscribe }
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackBeginCheckout: mockTrackBeginCheckout })
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => reactive({ userId: computed(() => mockUserId.value) })
}))
import { performTeamSubscriptionCheckout } from './teamSubscriptionCheckoutUtil'
@@ -53,9 +43,7 @@ describe('performTeamSubscriptionCheckout', () => {
billing_op_id: 'op_1'
})
await performTeamSubscriptionCheckout('team_700', 'yearly', {
paymentIntentSource: 'deep_link'
})
await performTeamSubscriptionCheckout('team_700', 'yearly')
expect(mockSubscribe).toHaveBeenCalledWith('team_per_credit_annual', {
returnUrl: 'https://app.test/payment/success',
@@ -63,14 +51,6 @@ describe('performTeamSubscriptionCheckout', () => {
teamCreditStopId: 'team_700'
})
expect(assignedHref).toBe('https://stripe.test/pay')
expect(mockTrackBeginCheckout).toHaveBeenCalledWith({
user_id: 'user-1',
tier: 'team',
cycle: 'yearly',
checkout_type: 'new',
billing_op_id: 'op_1',
payment_intent_source: 'deep_link'
})
})
it('uses the monthly slug and lands in the app when no Stripe step is needed', async () => {
@@ -102,16 +82,6 @@ describe('performTeamSubscriptionCheckout', () => {
expect(assignedHref).toBeUndefined()
})
it('does not track begin_checkout when subscribe fails', async () => {
mockSubscribe.mockRejectedValueOnce(new Error('subscribe failed'))
await expect(
performTeamSubscriptionCheckout('team_700', 'yearly')
).rejects.toThrow('subscribe failed')
expect(mockTrackBeginCheckout).not.toHaveBeenCalled()
})
it('does nothing off cloud', async () => {
mockIsCloud.value = false

View File

@@ -1,16 +1,10 @@
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { getTeamPlanSlug } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import { isCloud } from '@/platform/distribution/types'
import type { PaymentIntentSource } from '@/platform/telemetry/types'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { trackWorkspaceCheckoutStarted } from '@/platform/workspace/utils/workspaceCheckoutTelemetry'
import type { BillingCycle } from './subscriptionTierRank'
interface PerformTeamSubscriptionCheckoutOptions {
paymentIntentSource?: PaymentIntentSource
}
/**
* Direct team-plan checkout for the marketing `/cloud/subscribe?tier=team` deep
* link: subscribes to the per-credit Team plan at the chosen slider stop and
@@ -28,8 +22,7 @@ interface PerformTeamSubscriptionCheckoutOptions {
*/
export async function performTeamSubscriptionCheckout(
teamCreditStopId: string,
billingCycle: BillingCycle,
options: PerformTeamSubscriptionCheckoutOptions = {}
billingCycle: BillingCycle
): Promise<void> {
if (!isCloud) return
@@ -40,14 +33,6 @@ export async function performTeamSubscriptionCheckout(
teamCreditStopId
})
trackWorkspaceCheckoutStarted({
tier: 'team',
cycle: billingCycle,
checkoutType: 'new',
billingOpId: response.billing_op_id,
paymentIntentSource: options.paymentIntentSource
})
if (response.status === 'needs_payment_method') {
// A needs_payment_method response without a URL is unusable: surface it to
// the caller's error handling rather than silently dropping the user home

View File

@@ -50,184 +50,3 @@ describe('buildFeedbackTypeformUrl', () => {
expect(url.hash).toBe('#distribution=ccloud&source=topbar')
})
})
describe('buildSupportUrl', () => {
const ORIGINAL_UA = navigator.userAgent
beforeEach(() => {
distribution.isCloud = false
distribution.isNightly = false
Object.defineProperty(navigator, 'userAgent', {
value: ORIGINAL_UA,
configurable: true
})
})
function setUserAgent(value: string) {
Object.defineProperty(navigator, 'userAgent', {
value,
configurable: true
})
}
async function importModule() {
vi.resetModules()
return import('./config')
}
it('defaults to the question form when no form is provided', async () => {
const { buildSupportUrl } = await importModule()
const url = new URL(buildSupportUrl())
expect(url.hostname).toBe('comfy-org.portal.usepylon.com')
expect(url.pathname).toBe('/forms/question')
})
it('routes to the requested form slug', async () => {
const { buildSupportUrl, SupportForm } = await importModule()
const url = new URL(buildSupportUrl(SupportForm.Billing))
expect(url.pathname).toBe('/forms/billing-refund-issue')
})
it('encodes spaces as %20 (not "+") in the query string', async () => {
setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0'
)
const { buildSupportUrl, SupportForm } = await importModule()
const raw = buildSupportUrl(SupportForm.Bug, {
userEmail: 'user@example.com',
os: 'macOS 14.5'
})
expect(raw).toContain('comfy_os=macOS%2014.5')
expect(raw).not.toContain('+')
})
it('omits fields with empty or null values', async () => {
const { buildSupportUrl, SupportForm } = await importModule()
const url = new URL(
buildSupportUrl(SupportForm.Question, {
userEmail: '',
userId: null,
os: undefined,
version: '1.45.0'
})
)
expect(url.searchParams.has('email')).toBe(false)
expect(url.searchParams.has('comfy_cloud_user_id')).toBe(false)
expect(url.searchParams.has('comfy_os')).toBe(false)
expect(url.searchParams.get('comfy_version')).toBe('1.45.0')
})
it('tags Cloud builds with comfy_environment=ccloud', async () => {
distribution.isCloud = true
const { buildSupportUrl } = await importModule()
const url = new URL(buildSupportUrl())
expect(url.searchParams.get('comfy_environment')).toBe('ccloud')
})
it('tags Nightly builds with comfy_environment=oss-nightly', async () => {
distribution.isNightly = true
const { buildSupportUrl } = await importModule()
const url = new URL(buildSupportUrl())
expect(url.searchParams.get('comfy_environment')).toBe('oss-nightly')
})
it('detects Chrome from the user agent', async () => {
setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
)
const { buildSupportUrl } = await importModule()
const url = new URL(buildSupportUrl())
expect(url.searchParams.get('browser')).toBe('Chrome 131')
})
it('detects Firefox from the user agent', async () => {
setUserAgent(
'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0'
)
const { buildSupportUrl } = await importModule()
const url = new URL(buildSupportUrl())
expect(url.searchParams.get('browser')).toBe('Firefox 121')
})
it('detects Edge before falling through to Chrome', async () => {
setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0'
)
const { buildSupportUrl } = await importModule()
const url = new URL(buildSupportUrl())
expect(url.searchParams.get('browser')).toBe('Edge 131')
})
it('forwards a product area override to the prefill', async () => {
const { buildSupportUrl, SupportForm } = await importModule()
const url = new URL(
buildSupportUrl(SupportForm.Billing, { productArea: 'Billing' })
)
expect(url.searchParams.get('product_area')).toBe('Billing')
})
})
describe('normalizeOsName', () => {
const ORIGINAL_UA = navigator.userAgent
beforeEach(() => {
Object.defineProperty(navigator, 'userAgent', {
value: ORIGINAL_UA,
configurable: true
})
})
function setUserAgent(value: string) {
Object.defineProperty(navigator, 'userAgent', {
value,
configurable: true
})
}
async function importModule() {
vi.resetModules()
return import('./config')
}
it('promotes "darwin" to the UA-detected macOS version', async () => {
setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5_0) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36'
)
const { normalizeOsName } = await importModule()
expect(normalizeOsName('darwin')).toBe('macOS 14.5.0')
})
it('promotes "win32" to the UA-detected Windows version', async () => {
setUserAgent(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0 Safari/537.36'
)
const { normalizeOsName } = await importModule()
expect(normalizeOsName('win32')).toBe('Windows 10/11')
})
it('promotes "linux" to "Linux" when UA reports Linux', async () => {
setUserAgent('Mozilla/5.0 (X11; Linux x86_64) Firefox/121.0')
const { normalizeOsName } = await importModule()
expect(normalizeOsName('linux')).toBe('Linux')
})
it('keeps a descriptive value untouched', async () => {
const { normalizeOsName } = await importModule()
expect(normalizeOsName('Ubuntu 22.04')).toBe('Ubuntu 22.04')
})
it('falls back to UA detection when the input is empty', async () => {
setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/131.0.0.0 Safari/537.36'
)
const { normalizeOsName } = await importModule()
expect(normalizeOsName(null)).toBe('macOS 10.15.7')
expect(normalizeOsName('')).toBe('macOS 10.15.7')
})
it('falls back to the kernel name when UA detection cannot resolve', async () => {
setUserAgent('SomeWeirdBot/1.0')
const { normalizeOsName } = await importModule()
expect(normalizeOsName('darwin')).toBe('darwin')
})
})

View File

@@ -1,189 +1,70 @@
import { isCloud, isNightly } from '@/platform/distribution/types'
/**
* Slug of a Pylon form under https://comfy-org.portal.usepylon.com/forms/.
* The form slug determines which ticket form opens and which fields are shown.
* Zendesk ticket form field IDs.
*/
export const SupportForm = {
Billing: 'billing-refund-issue',
Bug: 'report-a-bug',
FeatureRequest: 'feature-request',
PartnerNode: 'partner-node-issue',
Question: 'question'
} as const
export type SupportForm = (typeof SupportForm)[keyof typeof SupportForm]
/**
* Pylon custom-field slugs (URL keys) configured for the comfy-org workspace.
* Pylon prefill uses the slug — not the field UUID — as the URL key.
*/
const PYLON_FIELDS = {
EMAIL: 'email',
BROWSER: 'browser',
COMFY_CLOUD_USER_ID: 'comfy_cloud_user_id',
COMFY_ENVIRONMENT: 'comfy_environment',
COMFY_OS: 'comfy_os',
COMFY_VERSION: 'comfy_version',
PRODUCT_AREA: 'product_area'
const ZENDESK_FIELDS = {
/** Distribution tag (cloud vs OSS) */
DISTRIBUTION: 'tf_42243568391700',
/** User email (anonymous requester) */
ANONYMOUS_EMAIL: 'tf_anonymous_requester_email',
/** User email (authenticated) */
EMAIL: 'tf_40029135130388',
/** User ID */
USER_ID: 'tf_42515251051412'
} as const
const PYLON_FORMS_BASE_URL = 'https://comfy-org.portal.usepylon.com/forms/'
const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
/**
* Build environment tag for distinguishing tickets by build type.
* Gets the distribution identifier for tracking.
* Helps distinguish feedback from different build types.
*/
function getEnvironment(): 'ccloud' | 'oss-nightly' | 'oss' {
function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
if (isCloud) return 'ccloud'
if (isNightly) return 'oss-nightly'
return 'oss'
}
const SUPPORT_BASE_URL = 'https://support.comfy.org/hc/en-us/requests/new'
const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
/**
* Builds the feedback Typeform URL tagged with the current build environment
* Builds the feedback Typeform URL tagged with the current build distribution
* and the UI source that opened it. Tags are passed via the URL fragment
* (Typeform's hidden-field convention) so survey responses can be segmented
* by environment (cloud / oss-nightly / oss) and entry point.
* by distribution (cloud / oss-nightly / oss) and entry point.
*/
export function buildFeedbackTypeformUrl(
source: 'topbar' | 'action-bar' | 'help-center'
): string {
const params = new URLSearchParams({
distribution: getEnvironment(),
distribution: getDistribution(),
source
})
return `${FEEDBACK_TYPEFORM_BASE_URL}#${params.toString()}`
}
export interface SupportPrefill {
/** Authenticated user's email (for Cloud / API-key users). */
userEmail?: string | null
/** Cloud user id, when available. */
userId?: string | null
/** Operating system string (e.g. "macOS 14.5"). */
os?: string | null
/** ComfyUI frontend version. */
version?: string | null
/** Product area this ticket belongs to (e.g. "Billing", "Cloud"). */
productArea?: string | null
}
/**
* Encode a single `slug=value` pair. Skips empty values so the resulting URL
* stays clean. We use `encodeURIComponent` (not `URLSearchParams`) so spaces
* become `%20` rather than `+`, matching the Pylon prefill spec.
*/
function encodePair(
slug: string,
value: string | null | undefined
): string | null {
if (value === null || value === undefined || value === '') return null
return `${encodeURIComponent(slug)}=${encodeURIComponent(value)}`
}
function detectBrowser(): string | null {
if (typeof navigator === 'undefined') return null
const ua = navigator.userAgent
// Order matters: Edge / Opera identify themselves as Chrome too.
const matchers: { name: string; pattern: RegExp }[] = [
{ name: 'Edge', pattern: /Edg\/([\d.]+)/ },
{ name: 'Opera', pattern: /OPR\/([\d.]+)/ },
{ name: 'Chrome', pattern: /Chrome\/([\d.]+)/ },
{ name: 'Firefox', pattern: /Firefox\/([\d.]+)/ },
{ name: 'Safari', pattern: /Version\/([\d.]+).*Safari/ }
]
for (const { name, pattern } of matchers) {
const match = ua.match(pattern)
if (match) return `${name} ${match[1].split('.')[0]}`
}
return null
}
/**
* Derive a user-friendly OS string from the browser. Preferred over backend
* platform names like `darwin` / `win32` because those are kernel identifiers,
* not what users (or support agents) recognize. Modern browsers freeze the
* macOS / Windows minor version in the UA string, so we only report the
* family — that's still more useful than `darwin`.
*/
export function detectOS(): string | null {
if (typeof navigator === 'undefined') return null
const ua = navigator.userAgent
if (/iPad|iPhone|iPod/.test(ua)) {
const iOS = ua.match(/OS (\d+)[._](\d+)(?:[._](\d+))?/)
return iOS ? `iOS ${iOS[1]}.${iOS[2]}${iOS[3] ? `.${iOS[3]}` : ''}` : 'iOS'
}
if (/Android/.test(ua)) {
const android = ua.match(/Android (\d+(?:\.\d+)*)/)
return android ? `Android ${android[1]}` : 'Android'
}
if (/Mac OS X|Macintosh/.test(ua)) {
const mac = ua.match(/Mac OS X (\d+)[._](\d+)(?:[._](\d+))?/)
if (!mac) return 'macOS'
return `macOS ${mac[1]}.${mac[2]}${mac[3] ? `.${mac[3]}` : ''}`
}
if (/Windows NT/.test(ua)) {
const win = ua.match(/Windows NT (\d+\.\d+)/)
const winMap: Record<string, string> = {
'10.0': 'Windows 10/11',
'6.3': 'Windows 8.1',
'6.2': 'Windows 8',
'6.1': 'Windows 7'
}
return win ? (winMap[win[1]] ?? `Windows NT ${win[1]}`) : 'Windows'
}
if (/CrOS/.test(ua)) return 'ChromeOS'
if (/Linux/.test(ua)) return 'Linux'
return null
}
/**
* Backend (`systemStats.system.os`) reports the Python platform identifier
* for OSS / Desktop, which is the kernel name (`darwin`, `linux`, `win32`).
* Promote those to the UA-detected version so the Pylon ticket shows
* "macOS 14.5" instead of "darwin".
*/
export function normalizeOsName(
rawOs: string | null | undefined
): string | null {
const uaOs = detectOS()
if (!rawOs) return uaOs
const lower = rawOs.toLowerCase().trim()
if (lower === 'darwin' || lower === 'linux' || lower === 'win32') {
return uaOs ?? rawOs
}
return rawOs
}
/**
* Builds the Pylon prefill URL for a given form, omitting empty fields.
* Users without prefill data still get a valid URL that opens the same form —
* Pylon will collect those values from the user manually.
* Builds the support URL with optional user information for pre-filling.
* Users without login information will still get a valid support URL without pre-fill.
*
* @param form - Which Pylon form to open
* @param prefill - Field values to pre-populate
* @returns Complete Pylon form URL
* @param params - User information to pre-fill in the support form
* @returns Complete Zendesk support URL with query parameters
*/
export function buildSupportUrl(
form: SupportForm = SupportForm.Question,
prefill: SupportPrefill = {}
): string {
const pairs: string[] = []
const push = (slug: string, value: string | null | undefined) => {
const pair = encodePair(slug, value)
if (pair) pairs.push(pair)
export function buildSupportUrl(params?: {
userEmail?: string | null
userId?: string | null
}): string {
const searchParams = new URLSearchParams({
[ZENDESK_FIELDS.DISTRIBUTION]: getDistribution()
})
if (params?.userEmail) {
searchParams.append(ZENDESK_FIELDS.ANONYMOUS_EMAIL, params.userEmail)
searchParams.append(ZENDESK_FIELDS.EMAIL, params.userEmail)
}
if (params?.userId) {
searchParams.append(ZENDESK_FIELDS.USER_ID, params.userId)
}
push(PYLON_FIELDS.EMAIL, prefill.userEmail)
push(PYLON_FIELDS.COMFY_CLOUD_USER_ID, prefill.userId)
push(PYLON_FIELDS.COMFY_ENVIRONMENT, getEnvironment())
push(PYLON_FIELDS.COMFY_VERSION, prefill.version)
push(PYLON_FIELDS.COMFY_OS, prefill.os)
push(PYLON_FIELDS.BROWSER, detectBrowser())
push(PYLON_FIELDS.PRODUCT_AREA, prefill.productArea)
const query = pairs.join('&')
return `${PYLON_FORMS_BASE_URL}${form}${query ? `?${query}` : ''}`
return `${SUPPORT_BASE_URL}?${searchParams.toString()}`
}

View File

@@ -1,52 +0,0 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import {
SupportForm,
buildSupportUrl,
normalizeOsName
} from '@/platform/support/config'
import type { SupportPrefill } from '@/platform/support/config'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
/**
* Resolves Pylon prefill data from the current user session + system stats and
* exposes a single `openSupport(form, extras?)` action that opens the best-fit
* Pylon form in a new tab.
*
* Resolution is deferred until `openSupport`/`buildPrefill` is actually called
* — call sites that never invoke them don't pay the cost of (or fail because
* of) booting Firebase auth at component setup time.
*/
export function useSupportContext() {
const buildPrefill = (extra?: Partial<SupportPrefill>): SupportPrefill => {
const { userEmail, resolvedUserInfo } = useCurrentUser()
const systemStatsStore = useSystemStatsStore()
return {
userEmail: userEmail.value ?? null,
userId: resolvedUserInfo.value?.id ?? null,
os: normalizeOsName(systemStatsStore.systemStats?.system?.os),
version: __COMFYUI_FRONTEND_VERSION__,
...extra
}
}
/**
* Open a Pylon support form pre-filled with the user's context. Any field
* we can't resolve is omitted from the URL — the form still opens.
*
* @param form - Which Pylon form best matches the entry-point. Defaults to
* the generic "Question" form.
* @param extra - Per-callsite overrides (e.g. `productArea: 'Billing'`).
*/
const openSupport = (
form: SupportForm = SupportForm.Question,
extra?: Partial<SupportPrefill>
): void => {
const url = buildSupportUrl(form, buildPrefill(extra))
window.open(url, '_blank', 'noopener,noreferrer')
}
return {
buildPrefill,
openSupport
}
}

Some files were not shown because too many files have changed in this diff Show More