Compare commits

...

1 Commits

Author SHA1 Message Date
Marwan Ahmed
e52354b23a feat: migrate support links from Zendesk to Pylon with context-aware routing
Replaces the Zendesk ticket form URL builder with a Pylon prefill builder and
routes each Support entry-point to the best-fit Pylon form, prefilled with the
user's email, cloud user id, environment, frontend version, OS, and browser.

- Help Center "Help" / topbar "Support" -> question form
- Error dialog & node "Get Help" -> report-a-bug form
- Subscription dialog, useSubscriptionActions, credits panel -> billing-refund-issue form
- Mobile linear-mode error -> report-a-bug form
- Cloud onboarding (signup, auth timeout, footer) -> question form

OS is normalized so Python platform names (darwin/linux/win32) are promoted to
UA-detected versions ("macOS 14.5", "Windows 10/11"); the Typeform feedback URL
is unchanged.
2026-05-21 18:45:40 +03:00
20 changed files with 519 additions and 123 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -70,10 +70,11 @@ 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'
@@ -114,16 +115,18 @@ 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 = async () => {
const showContactSupport = () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
await useCommandStore().execute('Comfy.ContactSupport')
openSupport(SupportForm.Bug, { productArea: 'Workflow Error' })
}
onMounted(async () => {

View File

@@ -118,9 +118,10 @@ import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useExternalLink } from '@/composables/useExternalLink'
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'
import { useAuthStore } from '@/stores/authStore'
import { formatMetronomeCurrency } from '@/utils/formatUtil'
@@ -135,7 +136,7 @@ const { buildDocsUrl, docsPaths } = useExternalLink()
const dialogService = useDialogService()
const authStore = useAuthStore()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const { openSupport } = useSupportContext()
const telemetry = useTelemetry()
const { isActiveSubscription } = useBillingContext()
const loading = computed(() => authStore.loading)
@@ -168,13 +169,13 @@ const handleCreditsHistoryClick = async () => {
await authActions.accessBillingPortal()
}
const handleMessageSupport = async () => {
const handleMessageSupport = () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'credits_panel'
})
await commandStore.execute('Comfy.ContactSupport')
openSupport(SupportForm.Billing, { productArea: 'Credits' })
}
const handleFaqClick = () => {

View File

@@ -47,6 +47,13 @@ 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: {
@@ -297,7 +304,7 @@ describe('ErrorNodeCard.vue', () => {
openSpy.mockRestore()
})
it('executes ContactSupport command when Get Help button is clicked', async () => {
it('opens the Pylon bug-report form when Get Help button is clicked', async () => {
const { user } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
@@ -308,7 +315,9 @@ describe('ErrorNodeCard.vue', () => {
await user.click(screen.getByRole('button', { name: /Get Help/ }))
expect(mockExecuteCommand).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(mockOpenSupport).toHaveBeenCalledWith('report-a-bug', {
productArea: 'Workflow Error'
})
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(),
execute: vi.fn(),
openSupport: 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('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mocks.execute
vi.mock('@/platform/support/useSupportContext', () => ({
useSupportContext: () => ({
openSupport: mocks.openSupport
})
}))
@@ -41,7 +41,7 @@ describe('useErrorActions', () => {
}
mocks.trackUiButtonClicked.mockReset()
mocks.trackHelpResourceClicked.mockReset()
mocks.execute.mockReset()
mocks.openSupport.mockReset()
windowOpenSpy = vi
.spyOn(window, 'open')
.mockImplementation(() => null as unknown as Window)
@@ -83,36 +83,31 @@ describe('useErrorActions', () => {
})
describe('contactSupport', () => {
it('tracks the help resource click and executes the contact support command', () => {
mocks.execute.mockReturnValue('executed')
it('tracks the help resource click and opens the Pylon bug-report form', () => {
const { contactSupport } = useErrorActions()
const result = contactSupport()
contactSupport()
expect(mocks.trackHelpResourceClicked).toHaveBeenCalledWith({
resource_type: 'help_feedback',
is_external: true,
source: 'error_dialog'
})
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(result).toBe('executed')
expect(mocks.openSupport).toHaveBeenCalledWith('report-a-bug', {
productArea: 'Workflow Error'
})
})
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', () => {
it('still opens the support form when telemetry is unavailable', () => {
mocks.telemetry = null
const { contactSupport } = useErrorActions()
void contactSupport()
contactSupport()
expect(mocks.trackHelpResourceClicked).not.toHaveBeenCalled()
expect(mocks.execute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(mocks.openSupport).toHaveBeenCalledWith('report-a-bug', {
productArea: 'Workflow Error'
})
})
})

View File

@@ -1,10 +1,11 @@
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 commandStore = useCommandStore()
const { openSupport } = useSupportContext()
const { staticUrls } = useExternalLink()
function openGitHubIssues() {
@@ -20,7 +21,7 @@ export function useErrorActions() {
is_external: true,
source: 'error_dialog'
})
return commandStore.execute('Comfy.ContactSupport')
openSupport(SupportForm.Bug, { productArea: 'Workflow Error' })
}
function findOnGitHub(errorMessage: string) {

View File

@@ -1,4 +1,3 @@
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,7 +21,8 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildSupportUrl } from '@/platform/support/config'
import { SupportForm } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { useTelemetry } from '@/platform/telemetry'
import type { ExecutionTriggerSource } from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -862,12 +862,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Contact Support',
versionAdded: '1.17.8',
function: () => {
const { userEmail, resolvedUserInfo } = useCurrentUser()
const supportUrl = buildSupportUrl({
userEmail: userEmail.value,
userId: resolvedUserInfo.value?.id
})
window.open(supportUrl, '_blank', 'noopener,noreferrer')
useSupportContext().openSupport(SupportForm.Question)
}
},
{

View File

@@ -51,7 +51,7 @@
<p class="mb-5 text-center text-sm text-gray-600">
{{ $t('cloudOnboarding.authTimeout.helpText') }}
<a
href="https://support.comfy.org"
:href="supportUrl"
class="cursor-pointer text-blue-400 no-underline"
target="_blank"
rel="noopener noreferrer"
@@ -75,6 +75,7 @@ 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
@@ -86,6 +87,10 @@ 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

@@ -120,7 +120,7 @@
<p class="mt-2 text-sm text-gray-600">
{{ t('cloudWaitlist_questionsText') }}
<a
href="https://support.comfy.org"
:href="supportUrl"
class="cursor-pointer text-blue-400 no-underline"
target="_blank"
rel="noopener noreferrer"
@@ -144,6 +144,7 @@ import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useFreeTierOnboarding } from '@/platform/cloud/onboarding/composables/useFreeTierOnboarding'
import { getSafePreviousFullPath } from '@/platform/cloud/onboarding/utils/previousFullPath'
import { isCloud } from '@/platform/distribution/types'
import { SupportForm, buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { SignUpData } from '@/schemas/signInSchema'
@@ -168,6 +169,10 @@ const {
} = useFreeTierOnboarding()
const googleSsoBlockedReason = getGoogleSsoBlockedReason()
const supportUrl = buildSupportUrl(SupportForm.Question, {
productArea: 'Cloud Onboarding'
})
const navigateToLogin = async () => {
await router.push({ name: 'cloud-login', query: route.query })
}

View File

@@ -15,7 +15,7 @@
{{ t('auth.login.privacyLink') }}
</a>
<a
href="https://support.comfy.org"
:href="supportUrl"
class="cursor-pointer text-sm text-gray-600 no-underline"
target="_blank"
rel="noopener noreferrer"
@@ -28,5 +28,11 @@
<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

@@ -155,8 +155,9 @@ 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 { useCommandStore } from '@/stores/commandStore'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
const { onClose, reason, onChooseTeam } = defineProps<{
@@ -184,7 +185,7 @@ const formattedMonthlyPrice = new Intl.NumberFormat(
maximumFractionDigits: 0
}
).format(MONTHLY_SUBSCRIPTION_PRICE)
const commandStore = useCommandStore()
const { openSupport } = useSupportContext()
const telemetry = useTelemetry()
// Always show custom pricing table for cloud subscriptions
@@ -215,13 +216,13 @@ const handleClose = () => {
onClose()
}
const handleContactUs = async () => {
const handleContactUs = () => {
telemetry?.trackHelpResourceClicked({
resource_type: 'help_feedback',
is_external: true,
source: 'subscription'
})
await commandStore.execute('Comfy.ContactSupport')
openSupport(SupportForm.Billing, { productArea: 'Billing' })
}
const handleViewEnterprise = () => {

View File

@@ -6,7 +6,7 @@ import { useSubscriptionActions } from '@/platform/cloud/subscription/composable
const mockFetchBalance = vi.fn()
const mockFetchStatus = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockExecute = vi.fn()
const mockOpenSupport = vi.fn()
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
@@ -32,9 +32,9 @@ vi.mock('@/services/dialogService', () => ({
})
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({
execute: mockExecute
vi.mock('@/platform/support/useSupportContext', () => ({
useSupportContext: () => ({
openSupport: mockOpenSupport
})
}))
@@ -59,26 +59,28 @@ describe('useSubscriptionActions', () => {
})
describe('handleMessageSupport', () => {
it('should execute support command and manage loading state', async () => {
it('opens the Pylon billing form and resets loading state', () => {
const { handleMessageSupport, isLoadingSupport } =
useSubscriptionActions()
expect(isLoadingSupport.value).toBe(false)
const promise = handleMessageSupport()
expect(isLoadingSupport.value).toBe(true)
handleMessageSupport()
await promise
expect(mockExecute).toHaveBeenCalledWith('Comfy.ContactSupport')
expect(mockOpenSupport).toHaveBeenCalledWith('billing-refund-issue', {
productArea: 'Billing'
})
expect(isLoadingSupport.value).toBe(false)
})
it('should handle errors gracefully', async () => {
mockExecute.mockRejectedValueOnce(new Error('Command failed'))
it('handles errors gracefully', () => {
mockOpenSupport.mockImplementationOnce(() => {
throw new Error('open failed')
})
const { handleMessageSupport, isLoadingSupport } =
useSubscriptionActions()
await handleMessageSupport()
handleMessageSupport()
expect(isLoadingSupport.value).toBe(false)
})
})

View File

@@ -3,9 +3,10 @@ import { onMounted, ref } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAuthActions } from '@/composables/auth/useAuthActions'
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
@@ -13,7 +14,7 @@ import { useCommandStore } from '@/stores/commandStore'
export function useSubscriptionActions() {
const dialogService = useDialogService()
const authActions = useAuthActions()
const commandStore = useCommandStore()
const { openSupport } = useSupportContext()
const telemetry = useTelemetry()
const { fetchStatus } = useBillingContext()
@@ -27,7 +28,7 @@ export function useSubscriptionActions() {
void dialogService.showTopUpCreditsDialog()
}
const handleMessageSupport = async () => {
const handleMessageSupport = () => {
try {
isLoadingSupport.value = true
if (isCloud) {
@@ -37,7 +38,7 @@ export function useSubscriptionActions() {
source: 'subscription'
})
}
await commandStore.execute('Comfy.ContactSupport')
openSupport(SupportForm.Billing, { productArea: 'Billing' })
} catch (error) {
console.error('[useSubscriptionActions] Error contacting support:', error)
} finally {

View File

@@ -50,3 +50,184 @@ 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('portal.usepylon.com')
expect(url.pathname).toBe('/comfy-org/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('/comfy-org/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,70 +1,189 @@
import { isCloud, isNightly } from '@/platform/distribution/types'
/**
* Zendesk ticket form field IDs.
* Slug of a Pylon form under https://portal.usepylon.com/comfy-org/forms/.
* The form slug determines which ticket form opens and which fields are shown.
*/
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'
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]
/**
* Gets the distribution identifier for tracking.
* Helps distinguish feedback from different build types.
* 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.
*/
function getDistribution(): 'ccloud' | 'oss-nightly' | 'oss' {
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'
} as const
const PYLON_FORMS_BASE_URL = 'https://portal.usepylon.com/comfy-org/forms/'
const FEEDBACK_TYPEFORM_BASE_URL = 'https://form.typeform.com/to/q7azbWPi'
/**
* Build environment tag for distinguishing tickets by build type.
*/
function getEnvironment(): '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 distribution
* Builds the feedback Typeform URL tagged with the current build environment
* 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 distribution (cloud / oss-nightly / oss) and entry point.
* by environment (cloud / oss-nightly / oss) and entry point.
*/
export function buildFeedbackTypeformUrl(
source: 'topbar' | 'action-bar' | 'help-center'
): string {
const params = new URLSearchParams({
distribution: getDistribution(),
distribution: getEnvironment(),
source
})
return `${FEEDBACK_TYPEFORM_BASE_URL}#${params.toString()}`
}
/**
* 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 params - User information to pre-fill in the support form
* @returns Complete Zendesk support URL with query parameters
*/
export function buildSupportUrl(params?: {
export interface SupportPrefill {
/** Authenticated user's email (for Cloud / API-key users). */
userEmail?: string | null
/** Cloud user id, when available. */
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)
}
return `${SUPPORT_BASE_URL}?${searchParams.toString()}`
/** 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.
*
* @param form - Which Pylon form to open
* @param prefill - Field values to pre-populate
* @returns Complete Pylon form URL
*/
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)
}
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}` : ''}`
}

View File

@@ -0,0 +1,52 @@
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
}
}

View File

@@ -8,7 +8,8 @@ import Button from '@/components/ui/button/Button.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useExternalLink } from '@/composables/useExternalLink'
import { buildSupportUrl } from '@/platform/support/config'
import { SupportForm, buildSupportUrl } from '@/platform/support/config'
import { useSupportContext } from '@/platform/support/useSupportContext'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -25,7 +26,11 @@ const { copyToClipboard } = useCopyToClipboard()
const guideUrl = buildDocsUrl('troubleshooting/overview', {
includeLocale: true
})
const supportUrl = buildSupportUrl()
const { buildPrefill } = useSupportContext()
const supportUrl = buildSupportUrl(
SupportForm.Bug,
buildPrefill({ productArea: 'Linear Mode' })
)
const inputNodeIds = computed(() => {
const ids = new Set()