Compare commits

...

6 Commits

Author SHA1 Message Date
Benjamin Lu
1d5801d6ef feat: track funnel telemetry attributes (#12778)
## Summary

Adds the frontend telemetry attribution needed to analyze settings,
app-mode, and sharing funnel usage for MAR-321: re-enables three funnel
events that were disabled by default, attaches
app-mode/view-mode/dock-state context to UI click, run, and share
events, and adds a per-session `shell_layout` snapshot plus
right-side-panel toggle tracking.

## Changes

- **What**:
- Removes `setting_changed`, `template_filter_changed`, and
`ui_button_click` from the code-default `DEFAULT_DISABLED_EVENTS` lists
in the Mixpanel and PostHog providers, so these events now send by
default (see deployment note).
- `ui_button_click` now requires an `element_group`; all call sites are
tagged (`sidebar`, `queue`, `actionbar`, `breadcrumb`, `error_dialog`,
`errors_panel`, `graph_menu`, `graph_node`, `selection_toolbox`,
`node_library`, `workflow_actions`, `cloud_notification`, `app_mode`,
`top_menu`, `right_side_panel`) and the GTM provider forwards the field.
- Run events (`run_button_clicked`, GTM `run_workflow`) now carry
required `view_mode`/`is_app_mode` plus a new `dock_state`
(`docked`/`floating`), read from the `Comfy.MenuPosition.Docked`
localStorage key by a new `getActionbarDockState()` util.
- Share funnel events (`share_flow`, `share_link_opened`,
`shared_workflow_run`) now carry required `view_mode`/`is_app_mode`. A
new `useShareFlowContext()` composable dedupes the source/view-mode
context across the share dialog, URL copy field, and `useShareDialog`.
GTM `share_flow` forwards the new fields and still omits `share_id`.
- `shared_workflow_run` attribution is snapshotted onto the queued job
at queue time, so switching app/graph mode while a job runs no longer
misattributes the completion event (falls back to live values when no
snapshot exists).
- New `shell_layout` event fired once per session at graph-ready (cloud
only): `view_mode`, `is_app_mode`, `dock_state`, `actionbar_position`,
`active_sidebar_tab`, `right_side_panel_open`, `bottom_panel_open`,
`open_workflow_tabs`. Forwarded by Mixpanel and PostHog; not sent to
GTM.
- The right side panel open button (top menu) and close button now fire
`ui_button_click` (`right_side_panel_opened`/`right_side_panel_closed`),
covering the panel open-rate gap.
- **Dependencies**: None.

## Review Focus

- `view_mode`/`is_app_mode` changed from optional to required (typed as
`AppMode`) on run/share metadata — check no call sites were missed.
- The queue-time snapshot in `executionStore` (`queuedJob.viewMode ??
mode.value`) and its regression test.
- Share IDs remain limited to the providers/events that already carry
share attribution (GTM still strips `share_id`).
- `shell_layout` cadence is once per session (graph-ready idle
callback), matching the gap analysis's "session snapshot" wording.

Linear: MAR-321

Validation:
- `pnpm test:unit
src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.test.ts
src/platform/telemetry/providers/cloud/MixpanelTelemetryProvider.test.ts
src/platform/telemetry/providers/cloud/GtmTelemetryProvider.test.ts
src/platform/telemetry/utils/getShellLayoutSnapshot.test.ts
src/platform/workflow/sharing/components/ShareWorkflowDialogContent.test.ts
src/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader.test.ts
src/stores/executionStore.test.ts src/components/TopMenuSection.test.ts
src/components/graph/selectionToolbox/InfoButton.test.ts
src/components/rightSidePanel/errors/useErrorActions.test.ts
src/views/GraphView.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm knip`
- `git diff --check`

## Deployment note

`telemetry_disabled_events` is currently unset in the prod/staging/test
dynamicconfig rows, so the code-default change here is what enables
these events. The remote value remains available as a kill switch, but
it **replaces** the code defaults rather than merging: if ops sets it to
re-disable an event, the list must include every event that should stay
disabled (`tab_count_tracking`, `node_search`,
`node_search_result_selected`, `help_center_*`, `workflow_created`), not
just the new ones.
2026-06-12 19:02:41 +00:00
pythongosssss
193f23e8c2 Revert "feat: default search to essentials when graph is empty" (#12814)
Reverts Comfy-Org/ComfyUI_frontend#12377
2026-06-12 18:18:34 +00:00
AustinMroz
eaa6776559 Fix broken e2e test (#12818) 2026-06-12 18:08:47 +00:00
Dante
afd42525fe B2 - refactor(billing): complete the billing facade — resubscribe/topup + status fields (FE-904) (#12622)
## What
Implements **B2 — Complete the billing facade** from the FE
billing-divergence survey. Adds the members missing from the shared
`BillingContext` so components stop bypassing `useBillingContext` with
raw `workspaceApi` calls.

Part of the billing convergence plan — **FE-904** (parent **FE-903**).

## Why this PR — an *enabling* refactor (near-zero standalone user
value)
On its own B2 changes no endpoint and is user-invisible (see
**Behavioral impact**). Its entire purpose is to be the **prerequisite**
that unblocks the rest of the convergence — it gives the facade a single
entry point and the missing capability/state surface that the next
levers depend on:

- **Unblocks B3 — repoint direct-bypass consumers (the next PR; a live
bug fix).** `SubscribeButton` (`current_tier`) and
`PostHogTelemetryProvider` (the `subscription_tier` person property)
currently read the **legacy** `useSubscription` tier, so the value is
**stale/empty for team users today** (telemetry + analytics are wrong
right now). They can only be repointed to a correct, workspace-aware
tier by sourcing it from the facade — which requires the **`tier`** (and
`renewalDate`, for `FreeTierDialog`) fields **this PR adds**. Without B2
there is literally no facade `tier` to read.
- **Unblocks B6 — orientation banners.** The 6 billing-state banners
need `billingStatus` / `subscriptionStatus`, exposed here.
- **Unblocks B1 — dispatcher flip (personal → workspace path).** B1 can
only collapse the personal/team fork once (a) every billing operation
flows through the facade — no raw `workspaceApi` bypass left — and (b)
the facade actually supports `resubscribe`/`topup`. This PR removes the
last bypasses and completes the action surface so the unified personal
path will work. (B1 itself stays gated on the BE-DATA unification.)

## Changes
- **Contract** (`composables/billing/types.ts`): `BillingActions` gains
`resubscribe()` and `topup(amountCents)`; `BillingState` gains
`billingStatus`, `subscriptionStatus`, `tier`, `renewalDate`. Exported
`BillingStatus`, `BillingSubscriptionStatus`, `CreateTopupResponse` from
`workspaceApi`.
- **Workspace adapter** (`useWorkspaceBilling`): real wiring —
`workspaceApi.resubscribe()` / `createTopup()`, surfaces
`statusData.billing_status` / `subscription_status` /
`subscription_tier` / `renewal_date`.
- **Legacy adapter** (`useLegacyBilling`): equivalents — `resubscribe` =
fresh checkout via `useSubscription`; `topup` converts **cents →
dollars** through `purchaseCredits`; `billingStatus` = `null` (no legacy
concept); `subscriptionStatus` synthesized from active/cancelled flags.
- **Dispatcher** (`useBillingContext`): proxies the new members.
- **Orphaned callers migrated** off raw `workspaceApi`:
  - `SubscriptionPanelContentWorkspace.vue` → `resubscribe()`
  - `useSubscriptionCheckout.ts` → `resubscribe()`
  - `TopUpCreditsDialogContentWorkspace.vue` → `topup(amountCents)`

## Notes
- **Unit divergence absorbed:** the facade standardizes `topup` on
**cents**; the legacy adapter converts to dollars for
`/customers/credit`.
- **FE-only, no backend dependency** — safe to merge/deploy standalone;
independent of the B1 dispatcher flip (which is gated on the BE-DATA
unification).

## Behavioral impact (verified — safe to merge/deploy standalone)
This is a structural refactor: **endpoints, request payloads, and fetch
counts are unchanged**, and there is **no user-visible change** on
OSS/Desktop or Cloud-personal.

- **OSS / Desktop** (`teamWorkspacesEnabled` off): no change. The only
B2 code that runs is the eager `useAuthActions()` in `useLegacyBilling`
setup — side-effect-free, and already instantiated transitively via
`useSubscription` today. New computeds are lazy with zero readers; new
legacy `resubscribe`/`topup` are never invoked (their callers are
team-only surfaces).
- **Cloud personal**: no change. The migrated handlers are structurally
unreachable on the legacy path (dialog/panel variant gating,
`isCancelled` gated to `!isInPersonalWorkspace`).
- **Cloud team**: same endpoints/payloads/refresh counts. **One
intentional behavioral nuance:** routing `resubscribe`/`topup` through
the facade now toggles the shared `useBillingContext().isLoading` flag
during the call (the previous raw `workspaceApi` calls did not). This is
deliberate — it aligns these two with every other facade action
(`subscribe`, `cancelSubscription`, …). Net effect is at most a brief
loading-indicator flicker in the subscription panel; no change to
network, ordering, or state correctness.

> Follow-up (pre-existing, out of scope): **FE-932** — a completed
top-up refreshes balance but not status, so `subscription.hasFunds` can
be briefly stale. Predates B2 (`main` did balance-only too); to be fixed
with B6.

> Import-cycle note: this closes `useBillingContext → useLegacyBilling →
useAuthActions → useBillingContext`. It is module-eval safe — every
cross-cycle call is at composable-runtime, none at module top level.

## Verification
- `vue-tsc --noEmit`: clean.
- `oxlint --type-aware` on touched files: 0 errors / 0 warnings.
- Runtime no-op confirmed by an adversarial code-path review across the
3 build targets (OSS / Cloud-personal / Cloud-team).
- eslint + unit tests: deferred to CI.

Survey: **FE Billing API Divergence — Personal vs Team Workspace**
(Notion) — notes D4, P6, T1, E7, E9.

> Draft: opened for early review of the facade shape and the
legacy-equivalent semantics (esp. legacy `resubscribe` = fresh checkout,
and the cents/dollars conversion).

---------

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-06-12 04:02:43 +00:00
Matt Miller
0c392e53a2 fix(oauth): allow reverse-DNS custom-scheme redirects on consent (#12806)
## ELI-5

After you click Continue on the sign-in consent page, the page sends
your browser back to the app that asked. Our safety check only knew
about web-style addresses (`http://...`), so when the iOS app — whose
return address looks like `org.comfy.ios://...` — finished sign-in, the
page refused to deliver and showed "OAuth request failed." The fix:
instead of the page keeping a list of address styles it trusts, it now
asks the backend "what return address did this app register?" and goes
exactly there or nowhere. Truly dangerous addresses (ones that run code
in the page) stay banned outright.

## Problem

The consent success handler hard-allowlists `http(s)` for the
post-consent redirect (`oauthApi.ts`). That covers the loopback
redirects `comfy-desktop`/`comfy-cli` register, but rejects RFC 8252
reverse-DNS custom schemes — the callback shape native-app OAuth clients
use.

Live failure (prod, 2026-06-11, first `comfy-ios` sign-in test): user
approves consent → backend persists consent, consumes the auth request,
and mints an authorization code for `org.comfy.ios://oauth-callback` →
frontend throws `'unsafe scheme'` → user sees the generic **"OAuth
request failed"** → the code expires unused 60s later. Verified
end-to-end in the prod DB.

## Fix (final design — binding, not scheme lists)

Bind the post-consent navigation to the **challenge's registered
`redirect_uri`** (scheme + authority + path equality; the server only
appends `code`/`state` query params to the registered URI). The backend
supplies that field per-request — Comfy-Org/cloud#4230 — so the frontend
carries **zero per-client knowledge**: registering a future native
client is a backend-only change.

Layers:
1. **Executable-scheme denylist**
(`javascript:`/`data:`/`blob:`/`vbscript:`/`about:`) — unconditional;
the actual XSS line.
2. **Registration binding** when `challenge.redirect_uri` is present —
also rejects wrong-client redirects, which no scheme policy could.
3. **http(s)-only fallback** when the challenge doesn't surface
`redirect_uri` (older backend) — preserves today's behavior; the two PRs
can land in either order, but iOS sign-in needs both.

Also per the earlier review pass: navigation uses the parsed URL
(parser/sink consistency), malformed URLs throw structured errors,
single-navigation asserted.

## History

This PR went through three designs: dotted-scheme heuristic → four-lab
adversarial review (68 findings, Opus-judge consolidated) flagged the
heuristic as bypassable → exact scheme allowlist → product feedback
(hardcoding per-client schemes in shared frontend code doesn't scale and
shouldn't exist for non-product test apps) → registration binding, which
the review panel had independently flagged as the strongest option.
41/41 oauth tests passing.

## Tests
- Navigates: bound custom-scheme redirect
(`org.comfy.ios://oauth-callback?code=…` vs registered
`org.comfy.ios://oauth-callback`), http loopback (legacy fallback)
- Rejects: unbound custom scheme (fallback), wrong-client redirect
(`com.evil.app://…` vs registered iOS URI), path mutation, executable
schemes even when 'registered', malformed URLs

Related: BE-1341, BE-1350, Comfy-Org/cloud#4230.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 03:17:57 +00:00
AustinMroz
46526cfabd On mode toggle apply to group children (#12809)
When performing mode toggle operations (like bypass or mute) with a
group (the colored rectangles) selected, nodes contained within the
group will be considered selected and will have their state toggled.
<img width="1024" height="1024" alt="AnimateDiff_00002"
src="https://github.com/user-attachments/assets/c4e9db17-3fe8-4fd8-9012-0e9a0bc59707"
/>
2026-06-12 02:44:02 +00:00
77 changed files with 1205 additions and 364 deletions

View File

@@ -15,7 +15,9 @@ test.describe('Download page @smoke', () => {
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle('Download Comfy — Run AI Locally')
await expect(page).toHaveTitle(
'Download Comfy Desktop — Run AI on Your Hardware'
)
})
test('CloudBannerSection is visible with cloud link', async ({ page }) => {

View File

@@ -51,20 +51,6 @@ export class FeatureFlagHelper {
})
}
async setServerFlags(flags: Record<string, unknown>): Promise<void> {
await this.page.evaluate((flagMap: Record<string, unknown>) => {
const api = window.app!.api
api.serverFeatureFlags.value = {
...api.serverFeatureFlags.value,
...flagMap
}
}, flags)
}
async setServerFlag(name: string, value: unknown): Promise<void> {
await this.setServerFlags({ [name]: value })
}
/**
* Mock server feature flags via route interception on /api/features.
*/

View File

@@ -137,7 +137,8 @@ export const TestIds = {
colorPickerCurrentColor: 'color-picker-current-color',
colorBlue: 'blue',
colorRed: 'red',
convertSubgraph: 'convert-to-subgraph-button'
convertSubgraph: 'convert-to-subgraph-button',
bypass: 'bypass-button'
},
menu: {
moreMenuContent: 'more-menu-content'

View File

@@ -309,50 +309,6 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => {
)
})
test.describe('Empty graph defaults', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.featureFlags.setServerFlag(
'node_library_essentials_enabled',
true
)
})
test('Defaults to Essentials when graph is empty', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await comfyPage.nodeOps.clearGraph()
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
await searchBoxV2.open()
const essentialsBtn = searchBoxV2.rootCategoryButton(
RootCategory.Essentials
)
await expect(essentialsBtn).toBeVisible()
await expect(essentialsBtn).toHaveAttribute('aria-pressed', 'true')
})
test('Defaults to Most Relevant when graph has nodes', async ({
comfyPage
}) => {
const { searchBoxV2 } = comfyPage
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
await searchBoxV2.open()
await expect(searchBoxV2.categoryButton('most-relevant')).toHaveAttribute(
'aria-current',
'true'
)
await expect(
searchBoxV2.rootCategoryButton(RootCategory.Essentials)
).toHaveAttribute('aria-pressed', 'false')
})
})
test.describe('Search behavior', () => {
test('Search narrows results progressively', async ({ comfyPage }) => {
const { searchBoxV2 } = comfyPage

View File

@@ -129,23 +129,18 @@ test.describe('Selection Toolbox', { tag: ['@screenshot', '@ui'] }, () => {
}) => {
// A group + a KSampler node
await comfyPage.workflow.loadWorkflow('groups/single_group')
const bypass = comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass)
// Select group + node should show bypass button
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+A')
await expect(
comfyPage.page.locator(
'.selection-toolbox *[data-testid="bypass-button"]'
)
).toBeVisible()
// Deselect node (Only group is selected) should hide bypass button
await comfyPage.nodeOps.selectNodes(['KSampler'])
await expect(
comfyPage.page.locator(
'.selection-toolbox *[data-testid="bypass-button"]'
)
).toBeHidden()
await expect(bypass).toBeVisible()
await comfyPage.keyboard.delete()
// (Only empty group is selected) should hide bypass button
await comfyPage.keyboard.selectAll()
await expect(comfyPage.selectionToolbox).toBeVisible()
await expect(bypass).toBeHidden()
})
test.describe('Color Picker', () => {

View File

@@ -3,6 +3,8 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getGroupTitlePosition } from '@e2e/fixtures/utils/groupHelpers'
const CREATE_GROUP_HOTKEY = 'Control+g'
@@ -217,4 +219,40 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
)
}).toPass({ timeout: 5000 })
})
test('Bypassing a group bypasses contents', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.keyboard.selectAll()
await comfyPage.page.keyboard.press('.')
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
const toggleBypass = () =>
comfyPage.page.getByTestId(TestIds.selectionToolbox.bypass).click()
const bypassCount = () =>
comfyPage.page.evaluate(
() => graph!.nodes.filter((node) => node.mode === 4).length
)
expect(await bypassCount()).toBe(0)
const groupCount = () => comfyPage.page.evaluate(() => graph!.groups.length)
await expect.poll(groupCount, 'create group').toBe(1)
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await ksampler.select()
await toggleBypass()
await expect.poll(bypassCount, 'setup bypass of single node').toBe(1)
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
await comfyPage.page.mouse.click(groupPos.x, groupPos.y)
await toggleBypass()
await expect.poll(bypassCount, 'all nodes are set to bypassed').toBe(7)
await toggleBypass()
await expect.poll(bypassCount, 'all nodes are unbypassed').toBe(0)
await comfyPage.page.keyboard.down('Shift')
await ksampler.select()
await comfyPage.page.keyboard.up('Shift')
await toggleBypass()
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
})
})

View File

@@ -87,6 +87,14 @@ vi.mock('@/scripts/app', () => ({
}
}))
const mockTrackUiButtonClicked = vi.hoisted(() => vi.fn())
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackUiButtonClicked: mockTrackUiButtonClicked
})
}))
type WrapperOptions = {
pinia?: ReturnType<typeof createTestingPinia>
stubs?: Record<string, boolean | Component>
@@ -110,6 +118,9 @@ function createWrapper({
activeJobsShort: '{count} active | {count} active',
clearQueueTooltip: 'Clear queue'
}
},
rightSidePanel: {
togglePanel: 'Toggle properties panel'
}
}
}
@@ -266,6 +277,19 @@ describe('TopMenuSection', () => {
expect(screen.queryByTestId('active-jobs-indicator')).toBeNull()
})
it('tracks right side panel opens', async () => {
const { user } = createWrapper()
await user.click(
screen.getByRole('button', { name: 'Toggle properties panel' })
)
expect(mockTrackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
})
it('hides queue progress overlay when QPO V2 is enabled', async () => {
const pinia = createTestingPinia({ createSpy: vi.fn })
const settingStore = useSettingStore(pinia)

View File

@@ -78,7 +78,7 @@
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="rightSidePanelStore.togglePanel"
@click="openRightSidePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
@@ -148,6 +148,7 @@ import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useActionBarButtonStore } from '@/stores/actionBarButtonStore'
@@ -282,6 +283,14 @@ const rightSidePanelTooltipConfig = computed(() =>
buildTooltipConfig(t('rightSidePanel.togglePanel'))
)
function openRightSidePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_opened',
element_group: 'top_menu'
})
rightSidePanelStore.togglePanel()
}
// Maintain support for legacy topbar elements attached by custom scripts
const legacyCommandsContainerRef = ref<HTMLElement>()
const hasLegacyContent = ref(false)

View File

@@ -222,7 +222,8 @@ watch(visible, async (newVisible) => {
*/
useEventListener(dragHandleRef, 'mousedown', () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'actionbar_run_handle_drag_start'
button_id: 'actionbar_run_handle_drag_start',
element_group: 'actionbar'
})
})

View File

@@ -131,7 +131,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.onChangeTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_on_change_selected'
button_id: 'queue_mode_option_run_on_change_selected',
element_group: 'queue'
})
queueMode.value = 'change'
}
@@ -145,7 +146,8 @@ const queueModeMenuItemLookup = computed<Record<string, QueueModeMenuItem>>(
tooltip: t('menu.instantTooltip'),
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_mode_option_run_instant_selected'
button_id: 'queue_mode_option_run_instant_selected',
element_group: 'queue'
})
queueMode.value = 'instant-idle'
}
@@ -237,7 +239,8 @@ const queuePrompt = async (e: Event) => {
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted'
button_id: 'queue_run_multiple_batches_submitted',
element_group: 'queue'
})
}

View File

@@ -88,7 +88,8 @@ const home = computed(() => ({
isBlueprint: isBlueprint.value,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_root_selected'
button_id: 'breadcrumb_subgraph_root_selected',
element_group: 'breadcrumb'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -103,7 +104,8 @@ const items = computed(() => {
key: `subgraph-${subgraph.id}`,
command: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'breadcrumb_subgraph_item_selected'
button_id: 'breadcrumb_subgraph_item_selected',
element_group: 'breadcrumb'
})
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')

View File

@@ -40,7 +40,8 @@ function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source
button_id: source,
element_group: 'workflow_actions'
})
}
}

View File

@@ -101,7 +101,8 @@ const reportOpen = ref(false)
*/
const showReport = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_show_report_clicked'
button_id: 'error_dialog_show_report_clicked',
element_group: 'error_dialog'
})
reportOpen.value = true
}

View File

@@ -25,7 +25,8 @@ const queryString = computed(() => props.errorMessage + ' is:issue')
function openGitHubIssues() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_find_existing_issues_clicked'
button_id: 'error_dialog_find_existing_issues_clicked',
element_group: 'error_dialog'
})
const query = encodeURIComponent(queryString.value)
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`

View File

@@ -218,7 +218,8 @@ onMounted(() => {
*/
const onMinimapToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_minimap_toggle_clicked'
button_id: 'graph_menu_minimap_toggle_clicked',
element_group: 'graph_menu'
})
void commandStore.execute('Comfy.Canvas.ToggleMinimap')
}
@@ -228,7 +229,8 @@ const onMinimapToggleClick = () => {
*/
const onLinkVisibilityToggleClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_menu_hide_links_toggle_clicked'
button_id: 'graph_menu_hide_links_toggle_clicked',
element_group: 'graph_menu'
})
void commandStore.execute('Comfy.Canvas.ToggleLinkVisibility')
}

View File

@@ -101,6 +101,7 @@ const extensionToolboxCommands = computed<ComfyCommandImpl[]>(() => {
const {
hasAnySelection,
hasGroupedNodesSelection,
hasMultipleSelection,
isSingleNode,
isSingleSubgraph,
@@ -118,7 +119,10 @@ const showSubgraphButtons = computed(() => isSingleSubgraph.value)
const showBypass = computed(
() =>
isSingleNode.value || isSingleSubgraph.value || hasMultipleSelection.value
isSingleNode.value ||
isSingleSubgraph.value ||
hasMultipleSelection.value ||
hasGroupedNodesSelection.value
)
const showLoad3DViewer = computed(() => hasAny3DNodeSelected.value)
const showMaskEditor = computed(() => isSingleImageNode.value)

View File

@@ -65,7 +65,8 @@ describe('InfoButton', () => {
expect(openNodeInfoMock).toHaveBeenCalled()
expect(trackUiButtonClickedMock).toHaveBeenCalledWith({
button_id: 'selection_toolbox_node_info_opened'
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
})
})

View File

@@ -24,7 +24,8 @@ const onInfoClick = () => {
if (!openNodeInfo()) return
useTelemetry()?.trackUiButtonClicked({
button_id: 'selection_toolbox_node_info_opened'
button_id: 'selection_toolbox_node_info_opened',
element_group: 'selection_toolbox'
})
}
</script>

View File

@@ -14,6 +14,7 @@ import { getActiveGraphNodeIds } from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
@@ -106,6 +107,10 @@ const isSingleSubgraphNode = computed(() => {
})
function closePanel() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'right_side_panel_closed',
element_group: 'right_side_panel'
})
rightSidePanelStore.closePanel()
}

View File

@@ -58,7 +58,8 @@ describe('useErrorActions', () => {
openGitHubIssues()
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_github_issues_clicked'
button_id: 'error_tab_github_issues_clicked',
element_group: 'errors_panel'
})
expect(windowOpenSpy).toHaveBeenCalledWith(
mocks.staticUrls.githubIssues,
@@ -123,7 +124,8 @@ describe('useErrorActions', () => {
findOnGitHub('CUDA out of memory')
expect(mocks.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'error_tab_find_existing_issues_clicked'
button_id: 'error_tab_find_existing_issues_clicked',
element_group: 'errors_panel'
})
const expectedQuery = encodeURIComponent('CUDA out of memory is:issue')
expect(windowOpenSpy).toHaveBeenCalledWith(

View File

@@ -9,7 +9,8 @@ export function useErrorActions() {
function openGitHubIssues() {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_github_issues_clicked'
button_id: 'error_tab_github_issues_clicked',
element_group: 'errors_panel'
})
window.open(staticUrls.githubIssues, '_blank', 'noopener,noreferrer')
}
@@ -25,7 +26,8 @@ export function useErrorActions() {
function findOnGitHub(errorMessage: string) {
telemetry?.trackUiButtonClicked({
button_id: 'error_tab_find_existing_issues_clicked'
button_id: 'error_tab_find_existing_issues_clicked',
element_group: 'errors_panel'
})
const query = encodeURIComponent(errorMessage + ' is:issue')
window.open(

View File

@@ -5,12 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { Settings } from '@/schemas/apiSchema'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil'
import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue'
@@ -74,8 +71,7 @@ describe('NodeSearchBoxPopover', () => {
const NodeSearchContentStub = defineComponent({
name: 'NodeSearchContent',
props: {
filters: { type: Array, default: () => [] },
defaultRootFilter: { type: String, default: null }
filters: { type: Array, default: () => [] }
},
emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'],
setup(_, { emit }) {
@@ -83,8 +79,7 @@ describe('NodeSearchBoxPopover', () => {
emit('addNode', nodeDef, dragEvent)
return {}
},
template:
'<div data-testid="search-content-v2" :data-default-root-filter="defaultRootFilter"></div>'
template: '<div data-testid="search-content-v2"></div>'
})
const pinia = createTestingPinia({
@@ -281,75 +276,4 @@ describe('NodeSearchBoxPopover', () => {
)
})
})
describe('defaultRootFilter on dialog open', () => {
function setGraphNodes(nodes: unknown[]) {
const canvasStore = useCanvasStore()
canvasStore.canvas = {
graph: { nodes },
allow_searchbox: false,
setDirty: vi.fn(),
linkConnector: {
events: new EventTarget(),
reset: vi.fn(),
disconnectLinks: vi.fn()
}
} as unknown as ReturnType<typeof useCanvasStore>['canvas']
}
async function openSearch() {
useSearchBoxStore().visible = true
await nextTick()
}
it('defaults to Essentials when the graph is empty', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to Essentials when the canvas is not yet available', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
})
it('defaults to null when the graph has nodes', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
it('re-evaluates each time the dialog opens', async () => {
renderComponent({ 'Comfy.NodeSearchBoxImpl': 'default' })
setGraphNodes([])
await openSearch()
expect(screen.getByTestId('search-content-v2')).toHaveAttribute(
'data-default-root-filter',
RootCategory.Essentials
)
useSearchBoxStore().visible = false
await nextTick()
setGraphNodes([{ id: 1 }])
await openSearch()
expect(screen.getByTestId('search-content-v2')).not.toHaveAttribute(
'data-default-root-filter'
)
})
})
})

View File

@@ -27,7 +27,6 @@
<div v-if="useSearchBoxV2" role="search" class="relative">
<NodeSearchContent
:filters="nodeFilters"
:default-root-filter="defaultRootFilter"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@@ -78,8 +77,6 @@ import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import NodePreviewCard from '@/components/node/NodePreviewCard.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import NodeSearchContent from './v2/NodeSearchContent.vue'
import NodeSearchBox from './NodeSearchBox.vue'
@@ -91,7 +88,6 @@ let disconnectOnReset = false
const settingStore = useSettingStore()
const searchBoxStore = useSearchBoxStore()
const litegraphService = useLitegraphService()
const canvasStore = useCanvasStore()
const { trackFeatureUsed } = useSurveyFeatureTracking('node-search')
const { visible, newSearchBoxEnabled, useSearchBoxV2 } =
@@ -107,13 +103,6 @@ const enableNodePreview = computed(
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview') &&
windowWidth.value >= MIN_WIDTH_FOR_PREVIEW
)
const defaultRootFilter = ref<RootCategoryId | null>(null)
watch(visible, (isVisible) => {
if (!isVisible) return
defaultRootFilter.value = !canvasStore.canvas?.graph?.nodes?.length
? RootCategory.Essentials
: null
})
function getNewNodeLocation(): Point {
return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY]
@@ -138,6 +127,7 @@ function clearFilters() {
function closeDialog() {
visible.value = false
}
const canvasStore = useCanvasStore()
function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor')

View File

@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import {
createMockNodeDef,
setViewport,
@@ -231,48 +230,6 @@ describe('NodeSearchContent', () => {
})
})
it('should apply defaultRootFilter when provided and category is available', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'EssentialNode',
display_name: 'Essential Node',
essentials_category: 'basic'
}),
createMockNodeDef({
name: 'RegularNode',
display_name: 'Regular Node'
})
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Essential Node')
})
})
it('should ignore defaultRootFilter of Essentials when no essentials exist', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
name: 'FrequentNode',
display_name: 'Frequent Node'
})
])
vi.spyOn(useNodeFrequencyStore(), 'topNodeDefs', 'get').mockReturnValue([
useNodeDefStore().nodeDefsByName['FrequentNode']
])
renderComponent({ defaultRootFilter: RootCategory.Essentials })
await waitFor(() => {
const items = screen.getAllByTestId('node-item')
expect(items).toHaveLength(1)
expect(items[0]).toHaveTextContent('Frequent Node')
})
})
it('should show only API nodes when Partner Nodes filter is active', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({

View File

@@ -142,9 +142,8 @@ const sourceCategoryFilters: Record<string, (n: ComfyNodeDefImpl) => boolean> =
[RootCategory.Custom]: isCustomNode
}
const { filters, defaultRootFilter = null } = defineProps<{
const { filters } = defineProps<{
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[]
defaultRootFilter?: RootCategoryId | null
}>()
const emit = defineEmits<{
@@ -195,12 +194,8 @@ function onSearchFocus() {
if (isMobile.value) isSidebarOpen.value = false
}
const rootFilter = ref<RootCategoryId | null>(
defaultRootFilter === RootCategory.Essentials &&
!nodeAvailability.value.essential
? null
: defaultRootFilter
)
// Root filter from filter bar category buttons (radio toggle)
const rootFilter = ref<RootCategoryId | null>(null)
const rootFilterLabel = computed(() => {
switch (rootFilter.value) {

View File

@@ -150,7 +150,8 @@ const telemetry = useTelemetry()
function onLogoMenuClick(event: MouseEvent) {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_comfy_menu_opened'
button_id: 'sidebar_comfy_menu_opened',
element_group: 'sidebar'
})
menuRef.value?.toggle(event)
}
@@ -217,7 +218,8 @@ const extraMenuItems = computed(() => [
icon: 'icon-[lucide--settings]',
command: () => {
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_settings_menu_opened'
button_id: 'sidebar_settings_menu_opened',
element_group: 'sidebar'
})
showSettings()
}
@@ -329,7 +331,8 @@ const handleNodes2ToggleClick = () => {
const onNodes2ToggleChange = async (value: boolean) => {
await settingStore.set('Comfy.VueNodes.Enabled', value)
telemetry?.trackUiButtonClicked({
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`
button_id: `menu_nodes_2.0_toggle_${value ? 'enabled' : 'disabled'}`,
element_group: 'sidebar'
})
}
</script>

View File

@@ -138,19 +138,23 @@ const onTabClick = async (item: SidebarTabExtension) => {
if (isNodeLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_node_library_selected'
button_id: 'sidebar_tab_node_library_selected',
element_group: 'sidebar'
})
else if (isModelLibraryTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_model_library_selected'
button_id: 'sidebar_tab_model_library_selected',
element_group: 'sidebar'
})
else if (isWorkflowsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_workflows_selected'
button_id: 'sidebar_tab_workflows_selected',
element_group: 'sidebar'
})
else if (isAssetsTab)
telemetry?.trackUiButtonClicked({
button_id: 'sidebar_tab_assets_media_selected'
button_id: 'sidebar_tab_assets_media_selected',
element_group: 'sidebar'
})
await commandStore.commands

View File

@@ -21,7 +21,8 @@ const bottomPanelStore = useBottomPanelStore()
*/
const toggleConsole = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_bottom_panel_console_toggled'
button_id: 'sidebar_bottom_panel_console_toggled',
element_group: 'sidebar'
})
bottomPanelStore.toggleBottomPanel()
}

View File

@@ -30,7 +30,8 @@ const tooltipText = computed(
const showSettingsDialog = () => {
command.function()
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked'
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
}
</script>

View File

@@ -37,7 +37,8 @@ const tooltipText = computed(
*/
const toggleShortcutsPanel = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_shortcuts_panel_toggled'
button_id: 'sidebar_shortcuts_panel_toggled',
element_group: 'sidebar'
})
bottomPanelStore.togglePanel('shortcuts')
}

View File

@@ -29,7 +29,8 @@ const isSmall = computed(
*/
const openTemplates = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_templates_dialog_opened'
button_id: 'sidebar_templates_dialog_opened',
element_group: 'sidebar'
})
useWorkflowTemplateSelectorDialog().show('sidebar')
}

View File

@@ -118,7 +118,8 @@ const toggleBookmark = async () => {
const onHelpClick = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'node_library_help_button'
button_id: 'node_library_help_button',
element_group: 'node_library'
})
props.openNodeHelp(nodeDef.value)
}

View File

@@ -2,6 +2,9 @@ import type { ComputedRef, Ref } from 'vue'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type {
BillingStatus,
BillingSubscriptionStatus,
CreateTopupResponse,
Plan,
PreviewSubscribeResponse,
SubscribeResponse,
@@ -16,7 +19,9 @@ export interface SubscriptionInfo {
tier: SubscriptionTier | null
duration: SubscriptionDuration | null
planSlug: string | null
/** ISO 8601 */
renewalDate: string | null
/** ISO 8601 */
endDate: string | null
isCancelled: boolean
hasFunds: boolean
@@ -44,6 +49,9 @@ export interface BillingActions {
) => Promise<PreviewSubscribeResponse | null>
manageSubscription: () => Promise<void>
cancelSubscription: () => Promise<void>
resubscribe: () => Promise<void>
/** `amountCents` must be a whole-dollar multiple of 100. */
topup: (amountCents: number) => Promise<CreateTopupResponse | void>
fetchPlans: () => Promise<void>
/**
* Ensures billing is initialized and subscription is active.
@@ -65,16 +73,12 @@ export interface BillingState {
currentPlanSlug: ComputedRef<string | null>
isLoading: Ref<boolean>
error: Ref<string | null>
/**
* Convenience computed for checking if subscription is active.
* Equivalent to `subscription.value?.isActive ?? false`
*/
isActiveSubscription: ComputedRef<boolean>
/**
* Whether the current billing context has a FREE tier subscription.
* Workspace-aware: reflects the active workspace's tier, not the user's personal tier.
*/
isFreeTier: ComputedRef<boolean>
billingStatus: ComputedRef<BillingStatus | null>
subscriptionStatus: ComputedRef<BillingSubscriptionStatus | null>
tier: ComputedRef<SubscriptionTier | null>
renewalDate: ComputedRef<string | null>
}
export interface BillingContext extends BillingState, BillingActions {

View File

@@ -5,13 +5,17 @@ import type { Plan } from '@/platform/workspace/api/workspaceApi'
import { useBillingContext } from './useBillingContext'
const { mockTeamWorkspacesEnabled, mockIsPersonal, mockPlans } = vi.hoisted(
() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] }
})
)
const {
mockTeamWorkspacesEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn()
}))
vi.mock('@vueuse/core', async (importOriginal) => {
const original = await importOriginal()
@@ -50,8 +54,9 @@ vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
isActiveSubscription: { value: true },
subscriptionTier: { value: 'PRO' },
subscriptionDuration: { value: 'MONTHLY' },
formattedRenewalDate: { value: 'Jan 1, 2025' },
formattedEndDate: { value: '' },
subscriptionStatus: {
value: { renewal_date: '2025-01-01T00:00:00Z', end_date: null }
},
isCancelled: { value: false },
fetchStatus: vi.fn().mockResolvedValue(undefined),
manageSubscription: vi.fn().mockResolvedValue(undefined),
@@ -70,6 +75,12 @@ vi.mock(
})
)
vi.mock('@/composables/auth/useAuthActions', () => ({
useAuthActions: () => ({
purchaseCredits: mockPurchaseCredits
})
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
balance: { amount_micros: 5000000 },
@@ -129,7 +140,7 @@ describe('useBillingContext', () => {
tier: 'PRO',
duration: 'MONTHLY',
planSlug: null,
renewalDate: 'Jan 1, 2025',
renewalDate: '2025-01-01T00:00:00Z',
endDate: null,
isCancelled: false,
hasFunds: true
@@ -173,6 +184,13 @@ describe('useBillingContext', () => {
await expect(manageSubscription()).resolves.toBeUndefined()
})
it('converts topup cents to whole dollars for the legacy credit endpoint', async () => {
const { topup } = useBillingContext()
await topup(500)
expect(mockPurchaseCredits).toHaveBeenCalledWith(5)
})
it('provides isActiveSubscription convenience computed', () => {
const { isActiveSubscription } = useBillingContext()
expect(isActiveSubscription.value).toBe(true)

View File

@@ -122,6 +122,15 @@ function useBillingContextInternal(): BillingContext {
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
const billingStatus = computed(() =>
toValue(activeContext.value.billingStatus)
)
const subscriptionStatus = computed(() =>
toValue(activeContext.value.subscriptionStatus)
)
const tier = computed(() => toValue(activeContext.value.tier))
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
function getMaxSeats(tierKey: TierKey): number {
if (type.value === 'legacy') return 1
@@ -218,6 +227,14 @@ function useBillingContextInternal(): BillingContext {
return activeContext.value.cancelSubscription()
}
async function resubscribe() {
return activeContext.value.resubscribe()
}
async function topup(amountCents: number) {
return activeContext.value.topup(amountCents)
}
async function fetchPlans() {
return activeContext.value.fetchPlans()
}
@@ -241,6 +258,10 @@ function useBillingContextInternal(): BillingContext {
error,
isActiveSubscription,
isFreeTier,
billingStatus,
subscriptionStatus,
tier,
renewalDate,
getMaxSeats,
initialize,
@@ -250,6 +271,8 @@ function useBillingContextInternal(): BillingContext {
previewSubscribe,
manageSubscription,
cancelSubscription,
resubscribe,
topup,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog

View File

@@ -1,7 +1,10 @@
import { computed, ref } from 'vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import type {
BillingStatus,
BillingSubscriptionStatus,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
@@ -24,8 +27,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
isActiveSubscription: legacyIsActiveSubscription,
subscriptionTier,
subscriptionDuration,
formattedRenewalDate,
formattedEndDate,
subscriptionStatus: legacySubscriptionStatus,
isCancelled,
fetchStatus: legacyFetchStatus,
manageSubscription: legacyManageSubscription,
@@ -34,6 +36,7 @@ export function useLegacyBilling(): BillingState & BillingActions {
} = useSubscription()
const authStore = useAuthStore()
const authActions = useAuthActions()
const isInitialized = ref(false)
const isLoading = ref(false)
@@ -52,8 +55,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
tier: subscriptionTier.value,
duration: subscriptionDuration.value,
planSlug: null, // Legacy doesn't use plan slugs
renewalDate: formattedRenewalDate.value || null,
endDate: formattedEndDate.value || null,
renewalDate: legacySubscriptionStatus.value?.renewal_date ?? null,
endDate: legacySubscriptionStatus.value?.end_date ?? null,
isCancelled: isCancelled.value,
hasFunds: (authStore.balance?.amount_micros ?? 0) > 0
}
@@ -75,6 +78,18 @@ export function useLegacyBilling(): BillingState & BillingActions {
}
})
// Legacy has no coarse billing_status concept (workspace-only).
const billingStatus = computed<BillingStatus | null>(() => null)
const subscriptionStatus = computed<BillingSubscriptionStatus | null>(() => {
if (isCancelled.value) return 'canceled'
if (legacyIsActiveSubscription.value) return 'active'
return null
})
const tier = computed(() => subscriptionTier.value)
const renewalDate = computed(
() => legacySubscriptionStatus.value?.renewal_date ?? null
)
// Legacy billing doesn't have workspace-style plans
const plans = computed(() => [])
const currentPlanSlug = computed(() => null)
@@ -152,6 +167,16 @@ export function useLegacyBilling(): BillingState & BillingActions {
await legacyManageSubscription()
}
async function resubscribe(): Promise<void> {
// Legacy has no resubscribe endpoint; resubscribing is a fresh checkout.
await legacySubscribe()
}
async function topup(amountCents: number): Promise<void> {
// Facade standardizes on cents; legacy /customers/credit takes dollars.
await authActions.purchaseCredits(amountCents / 100)
}
async function fetchPlans(): Promise<void> {
// Legacy billing doesn't have workspace-style plans
// Plans are hardcoded in the UI for legacy subscriptions
@@ -179,6 +204,10 @@ export function useLegacyBilling(): BillingState & BillingActions {
error,
isActiveSubscription,
isFreeTier,
billingStatus,
subscriptionStatus,
tier,
renewalDate,
// Actions
initialize,
@@ -188,6 +217,8 @@ export function useLegacyBilling(): BillingState & BillingActions {
previewSubscribe,
manageSubscription,
cancelSubscription,
resubscribe,
topup,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog

View File

@@ -1,8 +1,10 @@
import { uniq } from 'es-toolkit'
import type { LGraphNode, Positionable } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode, Reroute } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { collectFromNodes } from '@/utils/graphTraversalUtil'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
/**
* Composable for handling selected LiteGraph items filtering and operations.
@@ -71,7 +73,13 @@ export function useSelectedLiteGraphItems() {
* the prior null-tolerance for callers wired to early-firing commands.
*/
const getSelectedNodesShallow = (): LGraphNode[] =>
Array.from(canvasStore.canvas?.selectedItems ?? []).filter(isLGraphNode)
uniq(
[...(canvasStore.canvas?.selectedItems ?? [])].flatMap((item) => {
if (isLGraphNode(item)) return [item]
if (isLGraphGroup(item)) return [...item.children].filter(isLGraphNode)
return []
})
)
/**
* Get only the selected nodes (LGraphNode instances) from the canvas.

View File

@@ -7,7 +7,12 @@ import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isImageNode, isLGraphNode, isLoad3dNode } from '@/utils/litegraphUtil'
import {
isImageNode,
isLGraphGroup,
isLGraphNode,
isLoad3dNode
} from '@/utils/litegraphUtil'
import { filterOutputNodes } from '@/utils/nodeFilterUtil'
export interface NodeSelectionState {
@@ -41,6 +46,11 @@ export function useSelectionState() {
const hasAnySelection = computed(() => selectedItems.value.length > 0)
const hasSingleSelection = computed(() => selectedItems.value.length === 1)
const hasMultipleSelection = computed(() => selectedItems.value.length > 1)
const hasGroupedNodesSelection = computed(() =>
selectedItems.value.some(
(item) => isLGraphGroup(item) && [...item.children].some(isLGraphNode)
)
)
const isSingleNode = computed(
() => hasSingleSelection.value && isLGraphNode(selectedItems.value[0])
@@ -112,6 +122,7 @@ export function useSelectionState() {
openNodeInfo,
hasAny3DNodeSelected,
hasAnySelection,
hasGroupedNodesSelection,
hasSingleSelection,
hasMultipleSelection,
isSingleNode,

View File

@@ -9,16 +9,26 @@ export type AppMode =
| 'builder:outputs'
| 'builder:arrange'
type WorkflowModeSource = {
activeMode: AppMode | null
initialMode: AppMode | null | undefined
}
export function getWorkflowMode(
workflow: WorkflowModeSource | null | undefined
): AppMode {
return workflow?.activeMode ?? workflow?.initialMode ?? 'graph'
}
export function isAppModeValue(mode: AppMode): boolean {
return mode === 'app' || mode === 'builder:arrange'
}
const enableAppBuilder = ref(true)
export function useAppMode() {
const workflowStore = useWorkflowStore()
const mode = computed(
() =>
workflowStore.activeWorkflow?.activeMode ??
workflowStore.activeWorkflow?.initialMode ??
'graph'
)
const mode = computed(() => getWorkflowMode(workflowStore.activeWorkflow))
const isBuilderMode = computed(
() => isSelectMode.value || isArrangeMode.value
@@ -29,9 +39,7 @@ export function useAppMode() {
() => isSelectInputsMode.value || isSelectOutputsMode.value
)
const isArrangeMode = computed(() => mode.value === 'builder:arrange')
const isAppMode = computed(
() => mode.value === 'app' || mode.value === 'builder:arrange'
)
const isAppMode = computed(() => isAppModeValue(mode.value))
const isGraphMode = computed(
() => mode.value === 'graph' || isSelectMode.value
)

View File

@@ -38,7 +38,8 @@ export function useHelpCenter() {
*/
const toggleHelpCenter = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'sidebar_help_center_toggled'
button_id: 'sidebar_help_center_toggled',
element_group: 'sidebar'
})
helpCenterStore.toggle()
}

View File

@@ -88,20 +88,23 @@ const { t } = useI18n()
onMounted(() => {
// Impression event — uses trackUiButtonClicked as no dedicated impression tracker exists
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_modal_impression'
button_id: 'cloud_notification_modal_impression',
element_group: 'cloud_notification'
})
})
function onDismiss() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_continue_locally_clicked'
button_id: 'cloud_notification_continue_locally_clicked',
element_group: 'cloud_notification'
})
useDialogStore().closeDialog()
}
function onExplore() {
useTelemetry()?.trackUiButtonClicked({
button_id: 'cloud_notification_explore_cloud_clicked'
button_id: 'cloud_notification_explore_cloud_clicked',
element_group: 'cloud_notification'
})
const params = new URLSearchParams({

View File

@@ -147,7 +147,8 @@ describe('OAuthConsentView', () => {
oauthRequestId: '550e8400-e29b-41d4-a716-446655440000',
csrfToken: 'csrf-token',
decision: 'allow',
workspaceId: 'personal-workspace'
workspaceId: 'personal-workspace',
expectedRedirectUri: 'http://127.0.0.1:50632/cb'
})
})

View File

@@ -283,7 +283,8 @@ async function submit(decision: 'allow' | 'deny') {
oauthRequestId: challenge.value.oauth_request_id,
csrfToken: challenge.value.csrf_token,
decision,
workspaceId
workspaceId,
expectedRedirectUri: challenge.value.redirect_uri
})
clearOAuthRequestId()
} catch (error) {

View File

@@ -220,6 +220,111 @@ describe('submitOAuthConsentDecision', () => {
).rejects.toThrow('redirect_url')
})
it('navigates to a reverse-DNS custom-scheme redirect_url (native clients)', async () => {
// RFC 8252 native-app callback — the comfy-ios client returns the
// authorization code via org.comfy.ios://oauth-callback. The backend
// has already validated the URL byte-identically against the client's
// registered redirect_uris.
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({
redirect_url: 'org.comfy.ios://oauth-callback?code=xyz&state=s'
})
)
const originalLocation = globalThis.location
const hrefSetter = vi.fn()
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: new Proxy(originalLocation, {
set(_target, prop, value) {
if (prop === 'href') {
hrefSetter(value)
return true
}
return Reflect.set(originalLocation, prop, value)
},
get(_target, prop) {
return Reflect.get(originalLocation, prop)
}
})
})
try {
await submitOAuthConsentDecision({
oauthRequestId: validChallenge.oauth_request_id,
csrfToken: validChallenge.csrf_token,
decision: 'allow',
workspaceId: 'personal-workspace',
expectedRedirectUri: 'org.comfy.ios://oauth-callback'
})
expect(hrefSetter).toHaveBeenCalledWith(
'org.comfy.ios://oauth-callback?code=xyz&state=s'
)
expect(hrefSetter).toHaveBeenCalledTimes(1)
} finally {
Object.defineProperty(globalThis, 'location', {
configurable: true,
value: originalLocation
})
}
})
it.for([
[
'org.comfy.ios://oauth-callback?code=xyz',
undefined,
'unsafe scheme',
'custom scheme with no expectedRedirectUri is unbindable, falls back to the http(s)-only rule'
],
[
'com.evil.app://oauth-callback?code=xyz',
'org.comfy.ios://oauth-callback',
'does not match',
'bound challenge, different scheme: wrong-client redirect'
],
[
'org.comfy.ios://oauth-callback/../steal?code=xyz',
'org.comfy.ios://oauth-callback',
'does not match',
'bound challenge, same scheme but different path'
],
[
'javascript:alert(1)',
'javascript:alert(1)',
'unsafe scheme',
'executable schemes are rejected even if the challenge claims them'
],
[
'data:text/html,<script>alert(1)</script>',
'data:text/html,x',
'unsafe scheme',
'data: scheme rejected even if the challenge claims it'
],
[
'blob:https://cloud.comfy.org/abc',
undefined,
'unsafe scheme',
'blob: scheme is unsafe'
]
] as const)(
'rejects redirect_url %s (registration %s, expects %s): %s',
async ([redirectUrl, expectedRedirectUri, expectedError]) => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
okResponse({ redirect_url: redirectUrl })
)
await expect(
submitOAuthConsentDecision({
oauthRequestId: validChallenge.oauth_request_id,
csrfToken: validChallenge.csrf_token,
decision: 'allow',
workspaceId: 'personal-workspace',
expectedRedirectUri
})
).rejects.toThrow(expectedError)
}
)
it('rejects an unsafe redirect_url scheme', async () => {
// Defense in depth: even though the cloud backend is trusted, never
// hand the browser off to a non-http(s) URL.

View File

@@ -40,12 +40,33 @@ export type OAuthConsentDecisionParams = {
csrfToken: string
decision: 'allow' | 'deny'
workspaceId: string
/**
* The challenge's registered `redirect_uri`. When present, the
* post-consent navigation must match it (scheme, authority, path) —
* the server only appends `code`/`state` query params to the
* registered URI, so any other destination is rejected. When absent
* (challenges from backends that don't surface it yet), only http(s)
* redirects are navigable.
*/
expectedRedirectUri?: string
}
export type OAuthConsentDecision = (
params: OAuthConsentDecisionParams
) => Promise<void>
// Schemes that execute in our origin if navigated. Never navigable,
// regardless of what the backend returns. Everything else is governed
// by binding to the challenge's registered redirect_uri — no per-client
// scheme knowledge lives in the frontend.
const EXECUTABLE_SCHEMES: ReadonlySet<string> = new Set([
'javascript:',
'data:',
'blob:',
'vbscript:',
'about:'
])
export class OAuthApiError extends Error {
constructor(
message: string,
@@ -118,7 +139,8 @@ export async function submitOAuthConsentDecision({
oauthRequestId,
csrfToken,
decision,
workspaceId
workspaceId,
expectedRedirectUri
}: OAuthConsentDecisionParams): Promise<void> {
const response = await fetch('/oauth/authorize', {
method: 'POST',
@@ -144,13 +166,56 @@ export async function submitOAuthConsentDecision({
throw new Error('OAuth consent response did not include redirect_url')
}
// Defense in depth: even though the cloud backend is trusted, never hand
// the browser off to a non-http(s) scheme. javascript:/data: URLs would
// execute in our origin.
const target = new URL(redirectUrl, globalThis.location.origin)
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
// Defense in depth at this sink. Two risks: schemes that execute in our
// origin (always rejected, below), and the OS routing the authorization
// code + state to whichever installed app claims an arbitrary custom
// scheme. For the latter we hold the navigation to the redirect the
// backend registered for THIS auth request (the challenge's
// redirect_uri): the server only ever appends code/state query params
// to the registered URI, so scheme, authority, and path must match
// exactly. No per-client scheme list lives in the frontend — new native
// clients need only their backend registration.
const parseTarget = () => {
try {
return new URL(redirectUrl, globalThis.location.origin)
} catch (err) {
throw new Error('OAuth consent redirect_url is not a valid URL', {
cause: err
})
}
}
const target = parseTarget()
if (EXECUTABLE_SCHEMES.has(target.protocol)) {
throw new Error('OAuth consent redirect_url has an unsafe scheme')
}
if (expectedRedirectUri) {
const parseExpected = () => {
try {
return new URL(expectedRedirectUri)
} catch (err) {
throw new Error(
'OAuth consent challenge redirect_uri is not a valid URL',
{ cause: err }
)
}
}
const expected = parseExpected()
const matchesRegistration =
target.protocol === expected.protocol &&
target.host === expected.host &&
target.pathname === expected.pathname
if (!matchesRegistration) {
throw new Error(
'OAuth consent redirect_url does not match the registered redirect_uri'
)
}
} else if (target.protocol !== 'http:' && target.protocol !== 'https:') {
// Challenges that don't surface redirect_uri can't be bound; hold the
// pre-existing http(s)-only line for them.
throw new Error('OAuth consent redirect_url has an unsafe scheme')
}
globalThis.location.href = redirectUrl
// Navigate the parsed URL, not the raw string, so the value validated
// above is byte-for-byte the value the browser receives.
globalThis.location.href = target.href
}

View File

@@ -21,6 +21,7 @@ import type {
PageVisibilityMetadata,
SettingChangedMetadata,
SharedWorkflowRunMetadata,
ShellLayoutMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -196,6 +197,10 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackTabCount?.(metadata))
}
trackShellLayout(metadata: ShellLayoutMetadata): void {
this.dispatch((provider) => provider.trackShellLayout?.(metadata))
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.dispatch((provider) => provider.trackNodeSearch?.(metadata))
}

View File

@@ -1,4 +1,11 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: { value: 'app' },
isAppMode: { value: true }
})
}))
import { GtmTelemetryProvider } from './GtmTelemetryProvider'
@@ -18,6 +25,7 @@ describe('GtmTelemetryProvider', () => {
window.dataLayer = undefined
window.gtag = undefined
document.head.innerHTML = ''
localStorage.clear()
})
it('injects the GTM runtime script', () => {
@@ -184,11 +192,15 @@ describe('GtmTelemetryProvider', () => {
it('pushes run_workflow with trigger_source', () => {
const provider = createInitializedProvider()
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
provider.trackRunButton({ trigger_source: 'button' })
expect(lastDataLayerEntry()).toMatchObject({
event: 'run_workflow',
trigger_source: 'button',
subscribe_to_run: false
subscribe_to_run: false,
view_mode: 'app',
is_app_mode: true,
dock_state: 'floating'
})
})
@@ -323,16 +335,33 @@ describe('GtmTelemetryProvider', () => {
provider.trackShareFlow({
step: 'link_copied',
source: 'app_mode',
view_mode: 'app',
is_app_mode: true,
share_id: 'share-1'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'share_flow',
step: 'link_copied',
source: 'app_mode'
source: 'app_mode',
view_mode: 'app',
is_app_mode: true
})
expect(lastDataLayerEntry()).not.toHaveProperty('share_id')
})
it('pushes ui_button_click with element_group', () => {
const provider = createInitializedProvider()
provider.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
expect(lastDataLayerEntry()).toMatchObject({
event: 'ui_button_click',
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
})
it('omits share_id from workflow import events', () => {
const provider = createInitializedProvider()
provider.trackWorkflowImported({

View File

@@ -29,6 +29,8 @@ import type {
WorkflowImportMetadata,
WorkflowSavedMetadata
} from '../../types'
import { useAppMode } from '@/composables/useAppMode'
import { getActionbarDockState } from '../../utils/getActionbarDockState'
/**
* Google Tag Manager telemetry provider.
@@ -185,9 +187,14 @@ export class GtmTelemetryProvider implements TelemetryProvider {
subscribe_to_run?: boolean
trigger_source?: ExecutionTriggerSource
}): void {
const { mode, isAppMode } = useAppMode()
this.pushEvent('run_workflow', {
subscribe_to_run: options?.subscribe_to_run ?? false,
trigger_source: options?.trigger_source ?? 'unknown'
trigger_source: options?.trigger_source ?? 'unknown',
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
})
}
@@ -287,7 +294,9 @@ export class GtmTelemetryProvider implements TelemetryProvider {
trackShareFlow(metadata: ShareFlowMetadata): void {
this.pushEvent('share_flow', {
step: metadata.step,
source: metadata.source
source: metadata.source,
view_mode: metadata.view_mode,
is_app_mode: metadata.is_app_mode
})
}
@@ -333,7 +342,8 @@ export class GtmTelemetryProvider implements TelemetryProvider {
trackUiButtonClicked(metadata: UiButtonClickMetadata): void {
this.pushEvent('ui_button_click', {
button_id: metadata.button_id
button_id: metadata.button_id,
element_group: metadata.element_group
})
}

View File

@@ -19,7 +19,7 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: { value: 'workflow' },
mode: { value: 'graph' },
isAppMode: { value: false }
})
}))
@@ -60,7 +60,9 @@ import type {
DefaultViewSetMetadata,
EnterLinearMetadata,
ShareFlowMetadata,
ShellLayoutMetadata,
SurveyResponses,
TemplateFilterMetadata,
TemplateLibraryClosedMetadata,
TemplateLibraryMetadata,
TemplateMetadata,
@@ -74,6 +76,10 @@ const waitForMixpanelInit = () =>
type ConfigWindow = { __CONFIG__?: { mixpanel_token?: string } }
beforeEach(() => {
localStorage.clear()
})
describe('MixpanelTelemetryProvider — without configured token', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -165,6 +171,44 @@ describe('MixpanelTelemetryProvider — with configured token', () => {
expect(mockMixpanel.track).not.toHaveBeenCalled()
})
it('tracks enabled funnel events by default', async () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
const templateFilterMetadata: TemplateFilterMetadata = {
selected_models: [],
selected_use_cases: [],
selected_runs_on: [],
sort_by: 'default',
filtered_count: 1,
total_count: 2
}
provider.trackSettingChanged({ setting_id: 'theme' })
provider.trackTemplateFilterChanged(templateFilterMetadata)
provider.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.SETTING_CHANGED,
{ setting_id: 'theme' }
)
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
templateFilterMetadata
)
expect(mockMixpanel.track).toHaveBeenCalledWith(
TelemetryEvents.UI_BUTTON_CLICKED,
{
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
}
)
})
it.for<
[
'opened' | 'requested' | 'completed',
@@ -285,7 +329,21 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
default_view: 'graph'
}
const enterLinearMetadata: EnterLinearMetadata = {}
const shareFlowMetadata: ShareFlowMetadata = { step: 'dialog_opened' }
const shareFlowMetadata: ShareFlowMetadata = {
step: 'dialog_opened',
view_mode: 'graph',
is_app_mode: false
}
const shellLayoutMetadata: ShellLayoutMetadata = {
view_mode: 'graph',
is_app_mode: false,
dock_state: 'docked',
actionbar_position: 'Top',
active_sidebar_tab: null,
right_side_panel_open: false,
bottom_panel_open: false,
open_workflow_tabs: 1
}
const authMetadata: AuthMetadata = {}
it.for<
@@ -351,6 +409,11 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
(p) => p.trackShareFlow(shareFlowMetadata),
TelemetryEvents.SHARE_FLOW
],
[
'trackShellLayout',
(p) => p.trackShellLayout(shellLayoutMetadata),
TelemetryEvents.SHELL_LAYOUT
],
[
'trackAuth',
(p) => p.trackAuth(authMetadata),
@@ -391,6 +454,7 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
provider.trackRunButton({
subscribe_to_run: true,
@@ -403,8 +467,9 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
subscribe_to_run: true,
workflow_type: 'custom',
trigger_source: 'button',
view_mode: 'workflow',
is_app_mode: false
view_mode: 'graph',
is_app_mode: false,
dock_state: 'floating'
})
)
})
@@ -424,6 +489,8 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
provider.trackShareFlow({
step: 'link_copied',
source: 'app_mode',
view_mode: 'app',
is_app_mode: true,
share_id: 'share-1'
})
@@ -443,7 +510,9 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
TelemetryEvents.SHARE_FLOW,
{
step: 'link_copied',
source: 'app_mode'
source: 'app_mode',
view_mode: 'app',
is_app_mode: true
}
)
})

View File

@@ -28,6 +28,7 @@ import type {
RunButtonProperties,
SettingChangedMetadata,
ShareFlowMetadata,
ShellLayoutMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -47,6 +48,7 @@ import type {
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { TelemetryEvents } from '../../types'
import { getActionbarDockState } from '../../utils/getActionbarDockState'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
@@ -55,13 +57,10 @@ const DEFAULT_DISABLED_EVENTS = [
TelemetryEvents.TAB_COUNT_TRACKING,
TelemetryEvents.NODE_SEARCH,
TelemetryEvents.NODE_SEARCH_RESULT_SELECTED,
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
TelemetryEvents.SETTING_CHANGED,
TelemetryEvents.HELP_CENTER_OPENED,
TelemetryEvents.HELP_RESOURCE_CLICKED,
TelemetryEvents.HELP_CENTER_CLOSED,
TelemetryEvents.WORKFLOW_CREATED,
TelemetryEvents.UI_BUTTON_CLICKED
TelemetryEvents.WORKFLOW_CREATED
] as const satisfies TelemetryEventName[]
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
@@ -297,7 +296,8 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
}
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
@@ -397,6 +397,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
}
trackShellLayout(metadata: ShellLayoutMetadata): void {
this.trackEvent(TelemetryEvents.SHELL_LAYOUT, metadata)
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH, metadata)
}

View File

@@ -276,25 +276,50 @@ describe('PostHogTelemetryProvider', () => {
provider.trackShareLinkOpened({
share_id: 'share-1',
is_authenticated: true
is_authenticated: true,
view_mode: 'graph',
is_app_mode: false
})
provider.trackShareFlow({
step: 'link_created',
source: 'app_mode',
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
})
provider.trackSharedWorkflowRun({
job_id: 'job-1',
share_id: 'share-1'
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SHARE_LINK_OPENED,
{
share_id: 'share-1',
is_authenticated: true
is_authenticated: true,
view_mode: 'graph',
is_app_mode: false
}
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SHARE_FLOW,
{
step: 'link_created',
source: 'app_mode',
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
}
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SHARED_WORKFLOW_RUN,
{
job_id: 'job-1',
share_id: 'share-1'
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
}
)
})
@@ -444,6 +469,71 @@ describe('PostHogTelemetryProvider', () => {
{}
)
})
it('captures enabled funnel events by default', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
provider.trackSettingChanged({ setting_id: 'theme' })
provider.trackTemplateFilterChanged({
selected_models: [],
selected_use_cases: [],
selected_runs_on: [],
sort_by: 'default',
filtered_count: 1,
total_count: 2
})
provider.trackUiButtonClicked({
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
})
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SETTING_CHANGED,
{ setting_id: 'theme' }
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
{
selected_models: [],
selected_use_cases: [],
selected_runs_on: [],
sort_by: 'default',
filtered_count: 1,
total_count: 2
}
)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.UI_BUTTON_CLICKED,
{
button_id: 'sidebar_settings_button_clicked',
element_group: 'sidebar'
}
)
})
it('captures shell layout snapshots', async () => {
const provider = createProvider()
await vi.dynamicImportSettled()
const shellLayoutMetadata = {
view_mode: 'graph',
is_app_mode: false,
dock_state: 'floating',
actionbar_position: 'Top',
active_sidebar_tab: 'node-library',
right_side_panel_open: true,
bottom_panel_open: false,
open_workflow_tabs: 2
} as const
provider.trackShellLayout(shellLayoutMetadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
TelemetryEvents.SHELL_LAYOUT,
shellLayoutMetadata
)
})
})
describe('survey tracking', () => {

View File

@@ -28,6 +28,7 @@ import type {
RunButtonProperties,
SettingChangedMetadata,
SharedWorkflowRunMetadata,
ShellLayoutMetadata,
SubscriptionMetadata,
SubscriptionSuccessMetadata,
SurveyResponses,
@@ -45,6 +46,7 @@ import type {
WorkflowSavedMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { getActionbarDockState } from '../../utils/getActionbarDockState'
import { getExecutionContext } from '../../utils/getExecutionContext'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
@@ -54,13 +56,10 @@ const DEFAULT_DISABLED_EVENTS = [
TelemetryEvents.TAB_COUNT_TRACKING,
TelemetryEvents.NODE_SEARCH,
TelemetryEvents.NODE_SEARCH_RESULT_SELECTED,
TelemetryEvents.TEMPLATE_FILTER_CHANGED,
TelemetryEvents.SETTING_CHANGED,
TelemetryEvents.HELP_CENTER_OPENED,
TelemetryEvents.HELP_RESOURCE_CLICKED,
TelemetryEvents.HELP_CENTER_CLOSED,
TelemetryEvents.WORKFLOW_CREATED,
TelemetryEvents.UI_BUTTON_CLICKED
TelemetryEvents.WORKFLOW_CREATED
] as const satisfies TelemetryEventName[]
const TELEMETRY_EVENT_SET = new Set<TelemetryEventName>(
@@ -395,7 +394,8 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
toolkit_node_names: executionContext.toolkit_node_names,
trigger_source: options?.trigger_source,
view_mode: mode.value,
is_app_mode: isAppMode.value
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState()
}
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, runButtonProperties)
@@ -497,6 +497,10 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.TAB_COUNT_TRACKING, metadata)
}
trackShellLayout(metadata: ShellLayoutMetadata): void {
this.trackEvent(TelemetryEvents.SHELL_LAYOUT, metadata)
}
trackNodeSearch(metadata: NodeSearchMetadata): void {
this.trackEvent(TelemetryEvents.NODE_SEARCH, metadata)
}

View File

@@ -12,6 +12,7 @@
* 3. Check dist/assets/*.js files contain no tracking code
*/
import type { AppMode } from '@/composables/useAppMode'
import type { SubscriptionDialogReason } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscriptionTierRank'
@@ -70,8 +71,9 @@ export interface RunButtonProperties {
has_toolkit_nodes: boolean
toolkit_node_names: string[]
trigger_source?: ExecutionTriggerSource
view_mode?: string
is_app_mode?: boolean
view_mode: AppMode
is_app_mode: boolean
dock_state: ActionbarDockState
}
/**
@@ -120,8 +122,12 @@ export interface ExecutionSuccessMetadata {
export interface SharedWorkflowRunMetadata {
job_id: string
share_id: string
view_mode: AppMode
is_app_mode: boolean
}
export type ActionbarDockState = 'docked' | 'floating'
/**
* Template metadata for workflow tracking
*/
@@ -197,11 +203,15 @@ export interface ShareFlowMetadata {
step: ShareFlowStep
source?: 'app_mode' | 'graph_mode'
share_id?: string
view_mode: AppMode
is_app_mode: boolean
}
export interface ShareLinkOpenedMetadata {
share_id: string
is_authenticated: boolean
view_mode: AppMode
is_app_mode: boolean
}
/**
@@ -243,6 +253,20 @@ export interface TabCountMetadata {
tab_count: number
}
/**
* Shell layout snapshot, sent once per session when the app is ready
*/
export interface ShellLayoutMetadata {
view_mode: AppMode
is_app_mode: boolean
dock_state: ActionbarDockState
actionbar_position: string
active_sidebar_tab: string | null
right_side_panel_open: boolean
bottom_panel_open: boolean
open_workflow_tabs: number
}
/**
* Settings change metadata
*/
@@ -327,8 +351,8 @@ export interface TemplateFilterMetadata {
* UI button click tracking metadata
*/
export interface UiButtonClickMetadata {
/** Canonical identifier for the button (e.g., "comfy_logo") */
button_id: string
element_group: string
}
/**
@@ -498,6 +522,9 @@ export interface TelemetryProvider {
// Tab tracking events
trackTabCount?(metadata: TabCountMetadata): void
// Shell layout snapshot events
trackShellLayout?(metadata: ShellLayoutMetadata): void
// Node search analytics events
trackNodeSearch?(metadata: NodeSearchMetadata): void
trackNodeSearchResultSelected?(metadata: NodeSearchResultMetadata): void
@@ -593,6 +620,9 @@ export const TelemetryEvents = {
// Tab Tracking
TAB_COUNT_TRACKING: 'app:tab_count_tracking',
// Shell Layout
SHELL_LAYOUT: 'app:shell_layout',
// Node Search Analytics
NODE_SEARCH: 'app:node_search',
NODE_SEARCH_RESULT_SELECTED: 'app:node_search_result_selected',
@@ -655,6 +685,7 @@ export type TelemetryEventProperties =
| TemplateLibraryClosedMetadata
| PageVisibilityMetadata
| TabCountMetadata
| ShellLayoutMetadata
| NodeSearchMetadata
| NodeSearchResultMetadata
| SearchQueryMetadata

View File

@@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { getActionbarDockState } from './getActionbarDockState'
describe('getActionbarDockState', () => {
beforeEach(() => {
localStorage.clear()
})
it('returns docked when no preference is stored', () => {
expect(getActionbarDockState()).toBe('docked')
})
it('returns docked when the stored preference is true', () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'true')
expect(getActionbarDockState()).toBe('docked')
})
it('returns floating when the stored preference is false', () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
expect(getActionbarDockState()).toBe('floating')
})
})

View File

@@ -0,0 +1,7 @@
import type { ActionbarDockState } from '@/platform/telemetry/types'
export function getActionbarDockState(): ActionbarDockState {
return localStorage.getItem('Comfy.MenuPosition.Docked') === 'false'
? 'floating'
: 'docked'
}

View File

@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const state = vi.hoisted(() => ({
settings: {} as Record<string, unknown>,
activeSidebarTabId: null as string | null,
rightSidePanelOpen: false,
bottomPanelVisible: false,
openWorkflows: [] as unknown[],
mode: { value: 'graph' },
isAppMode: { value: false }
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({ mode: state.mode, isAppMode: state.isAppMode })
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: (key: string) => state.settings[key] })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({ openWorkflows: state.openWorkflows })
}))
vi.mock('@/stores/workspace/bottomPanelStore', () => ({
useBottomPanelStore: () => ({
bottomPanelVisible: state.bottomPanelVisible
})
}))
vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
useRightSidePanelStore: () => ({ isOpen: state.rightSidePanelOpen })
}))
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
useSidebarTabStore: () => ({
activeSidebarTabId: state.activeSidebarTabId
})
}))
import { getShellLayoutSnapshot } from './getShellLayoutSnapshot'
describe('getShellLayoutSnapshot', () => {
beforeEach(() => {
localStorage.clear()
state.settings = { 'Comfy.UseNewMenu': 'Top' }
state.activeSidebarTabId = null
state.rightSidePanelOpen = false
state.bottomPanelVisible = false
state.openWorkflows = []
state.mode.value = 'graph'
state.isAppMode.value = false
})
it('captures the default layout', () => {
expect(getShellLayoutSnapshot()).toEqual({
view_mode: 'graph',
is_app_mode: false,
dock_state: 'docked',
actionbar_position: 'Top',
active_sidebar_tab: null,
right_side_panel_open: false,
bottom_panel_open: false,
open_workflow_tabs: 0
})
})
it('captures a customized layout', () => {
localStorage.setItem('Comfy.MenuPosition.Docked', 'false')
state.activeSidebarTabId = 'node-library'
state.rightSidePanelOpen = true
state.bottomPanelVisible = true
state.openWorkflows = [{}, {}, {}]
state.mode.value = 'app'
state.isAppMode.value = true
expect(getShellLayoutSnapshot()).toEqual({
view_mode: 'app',
is_app_mode: true,
dock_state: 'floating',
actionbar_position: 'Top',
active_sidebar_tab: 'node-library',
right_side_panel_open: true,
bottom_panel_open: true,
open_workflow_tabs: 3
})
})
})

View File

@@ -0,0 +1,23 @@
import { useAppMode } from '@/composables/useAppMode'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { ShellLayoutMetadata } from '../types'
import { getActionbarDockState } from './getActionbarDockState'
export function getShellLayoutSnapshot(): ShellLayoutMetadata {
const { mode, isAppMode } = useAppMode()
return {
view_mode: mode.value,
is_app_mode: isAppMode.value,
dock_state: getActionbarDockState(),
actionbar_position: useSettingStore().get('Comfy.UseNewMenu'),
active_sidebar_tab: useSidebarTabStore().activeSidebarTabId,
right_side_panel_open: useRightSidePanelStore().isOpen,
bottom_panel_open: useBottomPanelStore().bottomPanelVisible,
open_workflow_tabs: useWorkflowStore().openWorkflows.length
}
}

View File

@@ -26,9 +26,9 @@ import { refAutoReset } from '@vueuse/core'
import Button from '@/components/ui/button/Button.vue'
import Input from '@/components/ui/input/Input.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useTelemetry } from '@/platform/telemetry'
import { useShareFlowContext } from '@/platform/workflow/sharing/composables/useShareFlowContext'
const { url, shareId } = defineProps<{
url: string
@@ -36,7 +36,7 @@ const { url, shareId } = defineProps<{
}>()
const { copyToClipboard } = useCopyToClipboard()
const { isAppMode } = useAppMode()
const shareFlowContext = useShareFlowContext()
const copied = refAutoReset(false, 2000)
async function handleCopy() {
@@ -44,7 +44,7 @@ async function handleCopy() {
copied.value = true
useTelemetry()?.trackShareFlow({
step: 'link_copied',
source: isAppMode.value ? 'app_mode' : 'graph_mode',
...shareFlowContext.value,
share_id: shareId
})
}

View File

@@ -387,6 +387,8 @@ describe('ShareWorkflowDialogContent', () => {
expect(mockTrackShareFlow).toHaveBeenCalledWith({
step: 'link_created',
source: 'graph_mode',
view_mode: 'graph',
is_app_mode: false,
share_id: 'test-123'
})
})
@@ -407,6 +409,8 @@ describe('ShareWorkflowDialogContent', () => {
expect(mockTrackShareFlow).toHaveBeenCalledWith({
step: 'link_copied',
source: 'graph_mode',
view_mode: 'graph',
is_app_mode: false,
share_id: 'copy-123'
})
})

View File

@@ -206,7 +206,7 @@ import type {
import { useWorkflowShareService } from '@/platform/workflow/sharing/services/workflowShareService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useAppMode } from '@/composables/useAppMode'
import { useShareFlowContext } from '@/platform/workflow/sharing/composables/useShareFlowContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useTelemetry } from '@/platform/telemetry'
import { appendJsonExt } from '@/utils/formatUtil'
@@ -223,11 +223,7 @@ const publishDialog = useComfyHubPublishDialog()
const shareService = useWorkflowShareService()
const workflowStore = useWorkflowStore()
const workflowService = useWorkflowService()
const { isAppMode } = useAppMode()
function getShareSource() {
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
}
const shareFlowContext = useShareFlowContext()
type DialogState = 'loading' | 'unsaved' | 'ready' | 'shared' | 'stale'
type DialogMode = 'shareLink' | 'publishToHub'
@@ -355,7 +351,7 @@ async function refreshDialogState() {
dialogState.value = 'unsaved'
useTelemetry()?.trackShareFlow({
step: 'save_prompted',
source: getShareSource()
...shareFlowContext.value
})
if (workflow) {
workflowName.value = stripJsonExtension(workflow.filename)
@@ -440,7 +436,7 @@ const {
acknowledged.value = false
useTelemetry()?.trackShareFlow({
step: 'link_created',
source: getShareSource(),
...shareFlowContext.value,
share_id: result.shareId
})

View File

@@ -1,5 +1,5 @@
import ShareWorkflowDialogContent from '@/platform/workflow/sharing/components/ShareWorkflowDialogContent.vue'
import { useAppMode } from '@/composables/useAppMode'
import { useShareFlowContext } from '@/platform/workflow/sharing/composables/useShareFlowContext'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
@@ -15,7 +15,7 @@ export function useShareDialog() {
const dialogStore = useDialogStore()
const { pruneLinearData } = useAppModeStore()
const workflowStore = useWorkflowStore()
const { isAppMode } = useAppMode()
const shareFlowContext = useShareFlowContext()
function hide() {
dialogStore.closeDialog({ key: DIALOG_KEY })
@@ -54,14 +54,10 @@ export function useShareDialog() {
share()
}
function getShareSource() {
return isAppMode.value ? 'app_mode' : ('graph_mode' as const)
}
function showShareDialog() {
useTelemetry()?.trackShareFlow({
step: 'dialog_opened',
source: getShareSource()
...shareFlowContext.value
})
dialogService.showLayoutDialog({
key: DIALOG_KEY,

View File

@@ -0,0 +1,18 @@
import { computed } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import type { ShareFlowMetadata } from '@/platform/telemetry/types'
type ShareFlowContext = Pick<
ShareFlowMetadata,
'source' | 'view_mode' | 'is_app_mode'
>
export function useShareFlowContext() {
const { mode, isAppMode } = useAppMode()
return computed<ShareFlowContext>(() => ({
source: isAppMode.value ? 'app_mode' : 'graph_mode',
view_mode: mode.value,
is_app_mode: isAppMode.value
}))
}

View File

@@ -38,6 +38,13 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
})
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
mode: { value: 'graph' },
isAppMode: { value: false }
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackShareLinkOpened: mockTrackShareLinkOpened
@@ -255,7 +262,9 @@ describe('useSharedWorkflowUrlLoader', () => {
)
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
share_id: 'share-id-1',
is_authenticated: false
is_authenticated: false,
view_mode: 'graph',
is_app_mode: false
})
expect(preservedQueryMocks.capturePreservedQuery).toHaveBeenCalledWith(
'share_auth',
@@ -281,7 +290,9 @@ describe('useSharedWorkflowUrlLoader', () => {
expect(loaded).toBe('loaded')
expect(mockTrackShareLinkOpened).toHaveBeenCalledWith({
share_id: 'share-id-1',
is_authenticated: true
is_authenticated: true,
view_mode: 'graph',
is_app_mode: false
})
expect(preservedQueryMocks.capturePreservedQuery).not.toHaveBeenCalled()
})

View File

@@ -3,6 +3,7 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { useTelemetry } from '@/platform/telemetry'
import OpenSharedWorkflowDialogContent from '@/platform/workflow/sharing/components/OpenSharedWorkflowDialogContent.vue'
@@ -45,6 +46,7 @@ export function useSharedWorkflowUrlLoader() {
const dialogStore = useDialogStore()
const templateSelectorDialog = useWorkflowTemplateSelectorDialog()
const { isLoggedIn } = useCurrentUser()
const { mode, isAppMode } = useAppMode()
const SHARE_NAMESPACE = PRESERVED_QUERY_NAMESPACES.SHARE
function isValidParameter(param: string): boolean {
@@ -146,7 +148,9 @@ export function useSharedWorkflowUrlLoader() {
useTelemetry()?.trackShareLinkOpened({
share_id: shareParam,
is_authenticated: isLoggedIn.value
is_authenticated: isLoggedIn.value,
view_mode: mode.value,
is_app_mode: isAppMode.value
})
if (!isLoggedIn.value) {
capturePreservedQuery(

View File

@@ -196,9 +196,13 @@ export interface PreviewSubscribeResponse {
new_plan: PreviewPlanInfo
}
type BillingSubscriptionStatus = 'active' | 'scheduled' | 'ended' | 'canceled'
export type BillingSubscriptionStatus =
| 'active'
| 'scheduled'
| 'ended'
| 'canceled'
type BillingStatus =
export type BillingStatus =
| 'awaiting_payment_method'
| 'pending_payment'
| 'paid'
@@ -233,7 +237,7 @@ interface CreateTopupRequest {
type TopupStatus = 'pending' | 'completed' | 'failed'
interface CreateTopupResponse {
export interface CreateTopupResponse {
billing_op_id: string
topup_id: string
status: TopupStatus

View File

@@ -371,7 +371,6 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { useSubscriptionActions } from '@/platform/cloud/subscription/composables/useSubscriptionActions'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useDialogService } from '@/services/dialogService'
import {
DEFAULT_TIER_KEY,
@@ -404,7 +403,8 @@ const {
manageSubscription,
fetchStatus,
fetchBalance,
getMaxSeats
getMaxSeats,
resubscribe
} = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
@@ -415,13 +415,12 @@ const isResubscribing = ref(false)
async function handleResubscribe() {
isResubscribing.value = true
try {
await workspaceApi.resubscribe()
await resubscribe()
toast.add({
severity: 'success',
summary: t('subscription.resubscribeSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
} catch (error) {
const message =
error instanceof Error ? error.message : 'Failed to resubscribe'

View File

@@ -161,7 +161,6 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import { useTelemetry } from '@/platform/telemetry'
import { clearTopupTracking } from '@/platform/telemetry/topupTracker'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useSettingsDialog } from '@/platform/settings/composables/useSettingsDialog'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
import { useDialogStore } from '@/stores/dialogStore'
@@ -177,7 +176,7 @@ const settingsDialog = useSettingsDialog()
const telemetry = useTelemetry()
const toast = useToast()
const { buildDocsUrl, docsPaths } = useExternalLink()
const { fetchBalance } = useBillingContext()
const { fetchBalance, topup } = useBillingContext()
const billingOperationStore = useBillingOperationStore()
const isPolling = computed(() => billingOperationStore.hasPendingOperations)
@@ -257,7 +256,8 @@ async function handleBuy() {
telemetry?.trackApiCreditTopupButtonPurchaseClicked(payAmount.value)
const amountCents = payAmount.value * 100
const response = await workspaceApi.createTopup(amountCents)
const response = await topup(amountCents)
if (!response) return
if (response.status === 'completed') {
toast.add({

View File

@@ -91,10 +91,12 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
previewSubscribe: mockPreviewSubscribe,
plans: computed(() => mockPlans.value),
fetchStatus: mockFetchStatus,
fetchBalance: mockFetchBalance
fetchBalance: mockFetchBalance,
resubscribe: mockResubscribe
})
}))
// Shields the test from the real workspaceApi → @/scripts/api → app.ts import chain
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
workspaceApi: { resubscribe: mockResubscribe }
}))

View File

@@ -11,7 +11,6 @@ import type {
Plan,
PreviewSubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
import { useBillingOperationStore } from '@/platform/workspace/stores/billingOperationStore'
type CheckoutStep = 'pricing' | 'preview'
@@ -35,8 +34,14 @@ export function useSubscriptionCheckout(emit: {
}) {
const { t } = useI18n()
const toast = useToast()
const { subscribe, previewSubscribe, plans, fetchStatus, fetchBalance } =
useBillingContext()
const {
subscribe,
previewSubscribe,
plans,
fetchStatus,
fetchBalance,
resubscribe
} = useBillingContext()
const telemetry = useTelemetry()
const billingOperationStore = useBillingOperationStore()
@@ -170,13 +175,12 @@ export function useSubscriptionCheckout(emit: {
async function handleResubscribe() {
isResubscribing.value = true
try {
await workspaceApi.resubscribe()
await resubscribe()
toast.add({
severity: 'success',
summary: t('subscription.resubscribeSuccess'),
life: 5000
})
await Promise.all([fetchStatus(), fetchBalance()])
emit('close', true)
} catch (error) {
const message =

View File

@@ -11,7 +11,9 @@ const mockWorkspaceApi = vi.hoisted(() => ({
subscribe: vi.fn(),
previewSubscribe: vi.fn(),
getPaymentPortalUrl: vi.fn(),
cancelSubscription: vi.fn()
cancelSubscription: vi.fn(),
resubscribe: vi.fn(),
createTopup: vi.fn()
}))
const mockBillingPlans = vi.hoisted(() => ({
@@ -622,6 +624,90 @@ describe('useWorkspaceBilling', () => {
})
})
describe('resubscribe', () => {
it('refreshes status and balance after a successful resubscribe', async () => {
mockWorkspaceApi.resubscribe.mockResolvedValue(undefined)
mockWorkspaceApi.getBillingStatus.mockResolvedValue(activeStatus)
mockWorkspaceApi.getBillingBalance.mockResolvedValue(positiveBalance)
const billing = setupBilling()
await billing.resubscribe()
expect(mockWorkspaceApi.resubscribe).toHaveBeenCalledTimes(1)
expect(mockWorkspaceApi.getBillingStatus).toHaveBeenCalledTimes(1)
expect(mockWorkspaceApi.getBillingBalance).toHaveBeenCalledTimes(1)
expect(billing.subscription.value?.tier).toBe('CREATOR')
expect(billing.balance.value?.amountMicros).toBe(5_000_000)
expect(billing.error.value).toBeNull()
expect(billing.isLoading.value).toBe(false)
})
it('sets error, rethrows, and skips the refresh when the API call fails', async () => {
mockWorkspaceApi.resubscribe.mockRejectedValue(
new Error('reactivation failed')
)
const billing = setupBilling()
await expect(billing.resubscribe()).rejects.toThrow('reactivation failed')
expect(billing.error.value).toBe('reactivation failed')
expect(billing.isLoading.value).toBe(false)
expect(mockWorkspaceApi.getBillingStatus).not.toHaveBeenCalled()
expect(mockWorkspaceApi.getBillingBalance).not.toHaveBeenCalled()
})
it('falls back to a generic error message for non-Error rejections', async () => {
mockWorkspaceApi.resubscribe.mockRejectedValue('boom')
const billing = setupBilling()
await expect(billing.resubscribe()).rejects.toBe('boom')
expect(billing.error.value).toBe('Failed to resubscribe')
})
})
describe('topup', () => {
const topupResponse = {
billing_op_id: 'op-topup',
topup_id: 'topup-1',
status: 'completed' as const,
amount_cents: 500
}
it('returns the createTopup response without refreshing status or balance', async () => {
mockWorkspaceApi.createTopup.mockResolvedValue(topupResponse)
const billing = setupBilling()
const result = await billing.topup(500)
expect(mockWorkspaceApi.createTopup).toHaveBeenCalledWith(500)
expect(result).toBe(topupResponse)
expect(mockWorkspaceApi.getBillingStatus).not.toHaveBeenCalled()
expect(mockWorkspaceApi.getBillingBalance).not.toHaveBeenCalled()
expect(billing.error.value).toBeNull()
expect(billing.isLoading.value).toBe(false)
})
it('sets error and rethrows when the API call fails', async () => {
mockWorkspaceApi.createTopup.mockRejectedValue(new Error('card declined'))
const billing = setupBilling()
await expect(billing.topup(500)).rejects.toThrow('card declined')
expect(billing.error.value).toBe('card declined')
expect(billing.isLoading.value).toBe(false)
})
it('falls back to a generic error message for non-Error rejections', async () => {
mockWorkspaceApi.createTopup.mockRejectedValue('boom')
const billing = setupBilling()
await expect(billing.topup(500)).rejects.toBe('boom')
expect(billing.error.value).toBe('Failed to top up credits')
})
})
describe('plans / currentPlanSlug / fetchPlans', () => {
it('prefers the plan slug from status over the billingPlans fallback', async () => {
mockBillingPlans.currentPlanSlug.value = 'plans-fallback'

View File

@@ -5,6 +5,7 @@ import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables
import type {
BillingBalanceResponse,
BillingStatusResponse,
CreateTopupResponse,
PreviewSubscribeResponse,
SubscribeResponse
} from '@/platform/workspace/api/workspaceApi'
@@ -70,6 +71,13 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
}
})
const billingStatus = computed(() => statusData.value?.billing_status ?? null)
const subscriptionStatus = computed(
() => statusData.value?.subscription_status ?? null
)
const tier = computed(() => statusData.value?.subscription_tier ?? null)
const renewalDate = computed(() => statusData.value?.renewal_date ?? null)
const plans = computed(() => billingPlans.plans.value)
const currentPlanSlug = computed(
() => statusData.value?.plan_slug ?? billingPlans.currentPlanSlug.value
@@ -262,6 +270,34 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
}
}
async function resubscribe(): Promise<void> {
isLoading.value = true
error.value = null
try {
await workspaceApi.resubscribe()
await Promise.all([fetchStatus(), fetchBalance()])
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to resubscribe'
throw err
} finally {
isLoading.value = false
}
}
async function topup(amountCents: number): Promise<CreateTopupResponse> {
isLoading.value = true
error.value = null
try {
return await workspaceApi.createTopup(amountCents)
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Failed to top up credits'
throw err
} finally {
isLoading.value = false
}
}
async function fetchPlans(): Promise<void> {
isLoading.value = true
error.value = null
@@ -303,6 +339,10 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
error,
isActiveSubscription,
isFreeTier,
billingStatus,
subscriptionStatus,
tier,
renewalDate,
// Actions
initialize,
@@ -312,6 +352,8 @@ export function useWorkspaceBilling(): BillingState & BillingActions {
previewSubscribe,
manageSubscription,
cancelSubscription,
resubscribe,
topup,
fetchPlans,
requireActiveSubscription,
showSubscriptionDialog

View File

@@ -57,7 +57,8 @@ async function runButtonClick(e: Event) {
if (batchCount.value > 1) {
useTelemetry()?.trackUiButtonClicked({
button_id: 'queue_run_multiple_batches_submitted'
button_id: 'queue_run_multiple_batches_submitted',
element_group: 'app_mode'
})
}
await commandStore.execute(commandId, {

View File

@@ -674,7 +674,8 @@ const handleToggleAdvanced = () => {
const handleEnterSubgraph = () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'graph_node_open_subgraph_clicked'
button_id: 'graph_node_open_subgraph_clicked',
element_group: 'graph_node'
})
const graph = app.rootGraph
if (!graph) {

View File

@@ -103,7 +103,8 @@ export const useDialogService = () => {
size: 'lg',
onClose: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_closed'
button_id: 'error_dialog_closed',
element_group: 'error_dialog'
})
}
}
@@ -169,7 +170,8 @@ export const useDialogService = () => {
size: 'lg',
onClose: () => {
useTelemetry()?.trackUiButtonClicked({
button_id: 'error_dialog_closed'
button_id: 'error_dialog_closed',
element_group: 'error_dialog'
})
}
}

View File

@@ -29,6 +29,25 @@ const {
mockTrackExecutionSuccess: vi.fn(),
mockTrackSharedWorkflowRun: vi.fn()
}))
const mockAppModeState = vi.hoisted(() => ({
mode: { value: 'graph' },
isAppMode: { value: false }
}))
vi.mock('@/composables/useAppMode', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
useAppMode: () => mockAppModeState
}
})
beforeEach(() => {
mockAppModeState.mode.value = 'graph'
mockAppModeState.isAppMode.value = false
})
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
@@ -1128,7 +1147,9 @@ describe('useExecutionStore - WebSocket event handlers', () => {
})
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1'
share_id: 'share-1',
view_mode: 'graph',
is_app_mode: false
})
})
@@ -1148,7 +1169,56 @@ describe('useExecutionStore - WebSocket event handlers', () => {
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1'
share_id: 'share-1',
view_mode: 'graph',
is_app_mode: false
})
})
it('attributes shared workflow run to queue-time mode, not completion-time mode', () => {
const workflow = createQueuedWorkflow()
workflow.shareId = 'share-1'
store.storeJob({
nodes: ['a'],
id: 'job-1',
promptOutput: {
a: createPromptNode('Node A', 'NodeA')
},
workflow
})
mockAppModeState.mode.value = 'app'
mockAppModeState.isAppMode.value = true
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1',
view_mode: 'graph',
is_app_mode: false
})
})
it('attributes shared workflow run to the queued workflow, not the active one', () => {
const workflow = createQueuedWorkflow()
workflow.shareId = 'share-1'
workflow.activeMode = 'app'
store.storeJob({
nodes: ['a'],
id: 'job-1',
promptOutput: {
a: createPromptNode('Node A', 'NodeA')
},
workflow
})
fire('execution_success', { prompt_id: 'job-1', timestamp: 0 })
expect(mockTrackSharedWorkflowRun).toHaveBeenCalledWith({
job_id: 'job-1',
share_id: 'share-1',
view_mode: 'app',
is_app_mode: true
})
})
})

View File

@@ -2,6 +2,12 @@ import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import type { AppMode } from '@/composables/useAppMode'
import {
getWorkflowMode,
isAppModeValue,
useAppMode
} from '@/composables/useAppMode'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -60,6 +66,12 @@ interface QueuedJob {
* `workflow.shareId`, which can gain attribution after the job was queued.
*/
shareId?: string
/**
* View-mode attribution snapshotted at queue time, so mode switches during
* the run don't misattribute completion events.
*/
viewMode?: AppMode
isAppMode?: boolean
}
function buildExecutionNodeLookup(
@@ -87,6 +99,7 @@ export const useExecutionStore = defineStore('execution', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const { mode, isAppMode } = useAppMode()
const clientId = ref<string | null>(null)
const activeJobId = ref<JobId | null>(null)
@@ -310,7 +323,9 @@ export const useExecutionStore = defineStore('execution', () => {
if (queuedJob.shareId) {
telemetry?.trackSharedWorkflowRun({
job_id: jobId,
share_id: queuedJob.shareId
share_id: queuedJob.shareId,
view_mode: queuedJob.viewMode ?? mode.value,
is_app_mode: queuedJob.isAppMode ?? isAppMode.value
})
}
}
@@ -594,6 +609,9 @@ export const useExecutionStore = defineStore('execution', () => {
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
queuedJob.workflow = workflow
queuedJob.shareId = workflow?.shareId
const queuedMode = getWorkflowMode(workflow)
queuedJob.viewMode = queuedMode
queuedJob.isAppMode = isAppModeValue(queuedMode)
const wid = workflow?.activeState?.id ?? workflow?.initialState?.id
if (wid) {
jobIdToWorkflowId.value.set(id, wid)

View File

@@ -68,6 +68,7 @@ import DesktopCloudNotificationController from '@/platform/cloud/notification/co
import { isCloud, isDesktop } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { getShellLayoutSnapshot } from '@/platform/telemetry/utils/getShellLayoutSnapshot'
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -351,6 +352,11 @@ const onGraphReady = () => {
tabCountChannel.postMessage({ type: 'heartbeat', tabId: currentTabId })
}
// Shell layout snapshot, once per session (cloud only)
if (isCloud && telemetry) {
telemetry.trackShellLayout(getShellLayoutSnapshot())
}
// Setting values now available after comfyApp.setup.
// Load keybindings.
wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()