Compare commits

..

66 Commits

Author SHA1 Message Date
Maanil Verma
7f52bc1667 docs(onboarding): drop comments that restate the code
Keep the ones that record a past bug or justify not taking the obvious
approach; delete the rest.
2026-07-18 18:18:37 +05:30
Maanil Verma
ae4df9e4ca test(onboarding): make template and role fixtures assert real behaviour
- Assert the preserved-intent hydration order and route replace instead
  of re-checking a mock's return value
- Type the roleResolver workflow fixtures to the schema so a field typo
  fails the build rather than passing for the wrong reason
- Drop a cast that misreported getNodeById's return type
2026-07-18 18:14:10 +05:30
Maanil Verma
adf5ba371f fix(onboarding): restore the conditional focus trap and guard templateFromUrl
- Trap focus only once the deferred target resolves, so the dialog isn't
  locked around a disabled button for the defer timeout
- Type templateFromUrl as optional and guard its read; an early exit left
  it unassigned
2026-07-18 18:13:48 +05:30
Maanil Verma
b2f37c4378 fix(onboarding): hold the nudge's default image until the result decodes
The branded fallback swapped the instant resultMedia was set, so it
flashed before the real media painted.

- Reveal the result only on load/loadeddata
- Unmount media that errors, so a dead URL falls back instead of showing
  a broken image
2026-07-18 18:13:22 +05:30
Maanil Verma
b2b1b4dfbb feat(onboarding): backfill Getting Started cards from fallback templates
A curated id missing from the backend data was dropped silently, shrinking
the grid below the four skeletons it renders.

- Fill the shortfall, in order, from FALLBACK_TEMPLATE_IDS
- Resolve tutorial thumbnails against a loaded template so a missing one
  no longer renders an empty src
2026-07-18 18:13:03 +05:30
Maanil Verma
b1bbef7932 fix(onboarding): gate the upgrade modal on the Run click
Reaching the Run step paywalled an unfunded user before they clicked
anything. Gate inside the click handler instead, and arm the listener
for every run step so credits exhausted mid-step still gate.
2026-07-18 18:12:39 +05:30
Maanil Verma
4e7f6a6a6b fix(onboarding): address coderabbit review findings 2026-07-17 16:59:45 +05:30
Maanil Verma
bfaf39d66c Merge branch 'pysssss/product-coachmarks' into feat/onboarding-getting-started
# Conflicts:
#	src/composables/useFeatureFlags.ts
#	src/platform/remoteConfig/types.ts
#	src/platform/telemetry/types.ts
2026-07-17 13:00:03 +05:30
Maanil Verma
e9caa313d4 test(onboarding): fix the coachmark drift-guard's assets-tab selector
getByRole('button', { name: 'Media Assets' }) never matched a real
button: the assets tab button is labelled "Assets" (its tooltip), while
"Media Assets" is only the panel header span (no button role). Open the
tab via the assetsTab page object, as appMode.spec already does.
2026-07-17 12:35:19 +05:30
Maanil Verma
62bb4d4346 Merge remote-tracking branch 'origin/main' into pysssss/product-coachmarks 2026-07-17 11:41:51 +05:30
AustinMroz
3be998aaf4 Add indicator for free tier quota system (#13657)
Adds frontend support for a quota based free tier.
- Adds a indicator component beneath the run button (docked/undocked/app
mode)
- Tracks available usages as workflows are queued. This is enforced by
backend, but updated quota information is not sent after initial load.
- Tracks partner node presence in graph (Workflows with partner nodes
are not allowed under the quota system)

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/c8dd53c3-9eff-4712-a4c2-c3d31bbbae35"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/c9410799-130d-4332-b2f8-20e993402f2c"
/>|

---------

Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2026-07-17 04:25:26 +00:00
Alexander Brown
99674df73b chore: add fallow dev dependency, scripts, and minimal config (#13271)
Adds the [fallow](https://github.com/fallow-rs/fallow)
codebase-intelligence analyzer as a devDependency to surface unused
code, duplication, and circular dependencies.

## Changes
- `package.json` — `fallow` devDep (`catalog:`) + scripts:
  - `pnpm fallow` — full-repo scan
  - `pnpm fallow:audit` — changed-files gate (suitable for CI)
- `pnpm-workspace.yaml` — `fallow` catalog entry
- `.fallowrc.json` — minimal config: entry points (`src/main.ts`,
`index.html`) + duplicate-clone threshold, ignoring generated files
- `.gitignore` — `.fallow/` cache dir

This PR only wires up the tool. The first scan surfaced cleanup
candidates (unused deps/exports, duplicate exports, circular deps);
those are tracked as separate follow-up PRs.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 00:32:29 +00:00
Dante
cfaf89edea feat: role-aware billing status banner for team workspaces (FE-1246) (#13641)
Adds the single billing-status banner slot for team plans, rendered in
priority order — **paused > payment declined > out of credits > ending**
— with role-aware CTAs. At most one state shows at a time.

Placement matches the prototype (`comfydesigner/team-workspaces-v1`) and
Figma: the banner lives in the **workspace settings panel**, in one slot
between the tab list and the tab body, so it shows on every tab. Our
container has two tabs, so one mount covers both rather than duplicating
the banner into each panel as the prototype does.

Derivation lives in a pure `deriveBillingBanner()`; the component only
renders. State comes from `billing-status` fields, never from error
strings (per Luke's user story).


## Gated on the plan, not the workspace type

`useBillingContext` gains `isTeamPlan`: a credit stop marks the
per-credit Team plan, a `team-` slug the retired seat-based ones.
Workspace type is the wrong question in both directions:

- A **team workspace can sit on a retired seat-based plan** (tier
STANDARD/CREATOR/PRO) — the type gate admits it.
- Once consolidated billing lands (cloud#5010), a **personal workspace
can hold a team plan**. The backend blocks that today pending BE-1526,
so the type gate is only accidentally right, and only for now. Per
[Hunter](https://comfy-organization.slack.com/archives/C0BEE5503RQ/p1784151520761519?thread_ts=1784051425.176829):
*"We should deprecate the workspace type and derive it from the
subscription."*

Two deliberate non-choices:

- **Not gated on `isActiveSubscription`**, unlike the existing
`isLegacyTeamPlan`. The spend gate folds `billing_status` into
`is_active`, so a paused or payment-failed team plan reports
`is_active=false` — it must still read as a team plan exactly when the
banner is needed.
- **Not gated on `subscription_tier === 'TEAM'`**, which would silently
drop every legacy team subscriber. The FE cannot express `'TEAM'`
anyway: `tierPricing.ts` resolves `SubscriptionTier` from the
**registry** spec for what is an **ingest** field. That's a real bug,
but orthogonal — filing separately.

## Fixes payment_failed, which was dead code

`payment_failed` denies spend, so it always arrives with
`is_active=false` — and the check sat **below** the `is_active` gate,
two lines under a comment documenting that exact trap for `paused`.
**Every team in Stripe dunning saw no banner.** Now hoisted alongside
`paused`.

Its tests passed only because they spread the `funded` fixture
(`isActiveSubscription: true`) onto `payment_failed` — a pairing the
backend never emits. They now pin `is_active=false`. Both this and the
`isTeamPlan` decoupling are mutation-tested: reverting either kills
tests.

Members no longer fall through from `payment_failed` to out-of-credits —
with the real pairing that path was unreachable, and DES-380 says
members never see the payment banner.

## Renders

The six role/state variants render from the Storybook stories at
`Platform/Workspace/BillingStatusBanner`. Each story drives the real
`deriveBillingBanner` through a stubbed billing context, so a story can
only show a state the backend can actually produce — not a hand-set
banner kind.

Run them with `DISTRIBUTION=cloud pnpm storybook` — the banner is
cloud-only and `isCloud` is compile-time, so a plain `pnpm storybook`
renders every story empty.

Icon language follows the prototype's severity rule: amber
triangle-alert for every action-needed state (paused, payment declined,
out of credits), muted circle-alert reserved for the informational "plan
ends" notice.

## Feature flag

Personal-workspace routing now keys off **`billing_control_enabled`**,
replacing `consolidated_billing_enabled` (which had exactly one consumer
— this routing check — so it is replaced rather than left alongside).
Cloud registers the new flag in
[Comfy-Org/cloud#5091](https://github.com/Comfy-Org/cloud/pull/5091).

This coupling is load-bearing for the banner. `isTeamPlan` requires the
workspace billing rail, so a personal workspace holding a team plan only
surfaces billing state once its routing flag is on. Had routing stayed
on `consolidated_billing_enabled` while the subscribe path unblocks
under `billing_control_enabled`, the two could diverge: a personal
workspace could buy a team plan and then be routed to legacy, which
carries no `billing_status` at all, blanking the banner.

**Merge order:** cloud#5091 must merge and `billing_control_enabled`
must exist in PostHog before this ships, otherwise the flag resolves
false for everyone and every personal workspace routes to legacy. Both
flags default false, so the floor is today's behaviour — but any users
already rolled out on `consolidated_billing_enabled` would revert to
legacy billing until `billing_control_enabled` is rolled out to them.
Worth confirming the current rollout state before merge.

## Backend status

`billing_status: 'paused'` merged in cloud
[#5075](https://github.com/Comfy-Org/cloud/pull/5075) but is **not
emitted yet**: the lifecycle handler that writes it is unreachable in
prod (ingest's forward allowlist omits `customer.subscription.*`;
billing-api is ClusterIP with no ingress). That's BE-1530's unfinished
half, tracked backend-side. The paused banner stays inert until then.

The other three states render from fields the API already returns:
`billing_status=payment_failed` + `renewal_date`, `has_funds`, and
`cancel_at`.

## How has this been tested?

- `useBillingContext.test.ts` — `isTeamPlan` across per-credit, legacy,
paused, payment-failed, and team-workspace-on-personal-plan
- `deriveBillingBanner.test.ts` — priority order, role gating, and the
realistic `is_active=false` pairing for both paused and payment_failed
- `BillingStatusBanner.test.ts` — per-variant copy and actions, date
interpolation + no-date fallback, cross-mount dismiss, ending read-only
for a non-original owner
- `useBillingBanner.test.ts` — dismiss resets after a top-up so a later
exhaustion re-shows
- `WorkspacePanelContent.test.ts` — the banner takes one slot above the
tab body
- 696 tests pass across the touched areas; typecheck / lint / knip clean

## Known gaps

- **Blocked-run surface:** settings-scoped per the prototype, so a user
who exhausts credits mid-canvas won't see it until they open settings.
Queue-time blocked-submit is carried by the existing
insufficient-credits dialog. An app-shell surface would be a design
call, not this PR.
- **Personal workspaces on consolidated billing** get no banner —
`isTeamPlan` excludes them by design, matching FE-1246's team scope and
the `!isInPersonalWorkspace` precedent in `useSubscriptionDialog`. Their
copy would need personal variants (`outOfCredits.body` says "Your
team…") and a free-tier decision. Belongs with the consolidated-billing
rollout.
- **Prototype slot:** the prototype's banner exposes an `actions` slot
used only by its Invoices view. We have no such view, so it's omitted
until needed.


## screenshots

<img width="1150" height="574" alt="01-paused-owner"
src="https://github.com/user-attachments/assets/c1ff0abd-0286-4453-85f2-6f89cff1e655"
/>
<img width="1150" height="574" alt="02-paused-member"
src="https://github.com/user-attachments/assets/ad5ce843-cbc6-49d1-95fc-28e908bc010a"
/>
<img width="1150" height="574" alt="03-payment-declined"
src="https://github.com/user-attachments/assets/717c1191-8f12-4835-90c6-5fe345b20780"
/>
<img width="1150" height="574" alt="04-payment-declined-no-date"
src="https://github.com/user-attachments/assets/c7e483e4-e0cb-4810-b090-a9c432546dbd"
/>
<img width="1150" height="574" alt="05-out-of-credits-owner"
src="https://github.com/user-attachments/assets/9261fd39-ecdb-4501-a401-47d9cbee04dc"
/>
<img width="1150" height="574" alt="06-out-of-credits-member"
src="https://github.com/user-attachments/assets/4a91af88-4405-43e1-8975-b64fdf180a13"
/>
<img width="1150" height="574" alt="07-ending-owner"
src="https://github.com/user-attachments/assets/80e5011e-ef11-482f-bc49-1ddfbe991221"
/>
<img width="1150" height="574" alt="08-ending-non-original-owner"
src="https://github.com/user-attachments/assets/129b79aa-1427-4286-829c-eed501a6d44d"
/>
2026-07-17 00:08:30 +00:00
Maanil Verma
2a2c4819ad Merge branch 'main' into feat/onboarding-getting-started 2026-07-17 00:39:17 +05:30
Maanil Verma
897e92f434 Merge remote-tracking branch 'origin/main' into feat/onboarding-getting-started 2026-07-17 00:31:31 +05:30
Maanil Verma
1d2ac9ca27 feat(onboarding): Getting Started screen and tour mounts
- Getting Started screen with curated template cards
- mount the first-run overlay, nudge, and Getting Started screen in GraphView
2026-07-16 23:50:46 +05:30
Maanil Verma
d23a00b5cb feat(onboarding): first-run canvas tour
- residual view store + controller bridging the shared coachmark engine
- overlay: multi-hole scrim, camera choreography, Floating UI placement, shared
  CoachmarkCard, result media and generating states
- post-run nudge with deferral behind the upgrade modal
- first-run telemetry funnel; e2e coverage
2026-07-16 23:49:20 +05:30
Maanil Verma
ea15f1af8a feat(onboarding): resolve tour roles from the loaded workflow
- roleResolver extracts source/prompt/engine/sink roles (subgraph-aware)
- sequenceBuilder turns roles into the ordered step list by shape
- toCoachSteps projects steps onto the shared engine (targetless, 1:1 aligned)
- subgraph navigation restores the root view
- vendored workflow_templates fixtures (marked generated) as resolver ground truth
2026-07-16 23:48:11 +05:30
Maanil Verma
6709d27c71 feat(onboarding): shared contracts for the first-run tour
- onboarding_tour feature flag (remoteConfig + useFeatureFlags)
- fold first-run telemetry events into the shared onboarding schema
- useDesktopLayout composable (shared by the tour gate and app-mode triggers)
- expose UPGRADE_DIALOG_KEYS; thread templateId through useTemplateUrlLoader
- data-testids on the action bar and tab list; onboarding candidate predicate
2026-07-16 23:36:11 +05:30
Maanil Verma
2697a43f78 feat(onboarding): canvas spotlight adapter for the first-run tour
Client-space geometry for canvas-anchored tour targets: node rects, camera
framing, mask rects, and transform-settle tracking.
2026-07-16 23:34:43 +05:30
Maanil Verma
d5fd3d10aa feat(onboarding): generalize the coachmark engine for a second consumer
- EntryPath adds 'firstRun'; TOURS is Partial so an injected tour needs no static entry
- startTour accepts a precomputed step definition; activeTour is exposed read-only
- engine telemetry and TourOverlay cover only registry (TOURS) tours; injected tours self-render and self-report
- TourOverlay.test mock provides activeTour for the new gate (assertions unchanged)
2026-07-16 21:40:28 +05:30
AustinMroz
13fd07201c Merge branch 'main' into pysssss/product-coachmarks 2026-07-13 12:34:39 -07:00
pythongosssss
62806d5ebd Merge branch 'main' into pysssss/product-coachmarks 2026-07-09 12:32:53 +01:00
pythongosssss
dff76dbc9d make button grow, match design 2026-07-08 03:38:48 -07:00
pythongosssss
66b9f329ce update wording 2026-07-08 03:22:56 -07:00
pythongosssss
cb1be63fb6 decrease padding 2026-07-08 03:17:09 -07:00
pythongosssss
3f5b71a472 refactor: use reka FocusScope for coachmark focus trapping
- Replace the custom two-subtree focus trap with reka FocusScope; the
  spotlight overlay is fully modal so a single-subtree trap suffices
- Focus the primary action via a template ref once it's actionable, keyed
  off waitingForTarget, and keep a global Escape-to-skip
- Add an e2e test asserting focus lands on the primary and Tab stays trapped
2026-07-07 07:24:49 -07:00
pythongosssss
7df585e606 flush -> nextTick 2026-07-06 12:47:08 -07:00
pythongosssss
f1f7c53fbe feat: rework coachmark assets step and add step-back navigation
- Auto-open the assets sidebar tab on the assets step instead of
  requiring a click on the Assets button (drops advanceOnTargetClick,
  skipIfMounted, the click-through blocker, and the idle pulse)
- Make the tour overlay fully modal and add a Back button to steps
- Cache the top-bar inset instead of reading it every animation frame
- Reset waitingForTarget when a deferred step aborts
- Dedupe the assets-panel coachmark binding in LinearView
2026-07-06 11:33:43 -07:00
pythongosssss
25ce1ebbe4 Merge remote-tracking branch 'origin/main' into pysssss/product-coachmarks
# Conflicts:
#	src/platform/telemetry/providers/cloud/PostHogTelemetryProvider.ts
#	src/platform/telemetry/types.ts
2026-07-06 08:15:27 -07:00
pythongosssss
97308500e9 fix: keep the coachmark tour alive when outputs clear
- Split triggers into holds/autoOpen: only leaving app mode or the
  desktop layout dismisses a running tour, not losing outputs
- Trap focus in the spotlight target only on click-to-advance steps,
  matching the pointer blocker
- Skip non-laid-out elements in the focus cycle
- Drop unused CoachId[] support
- Recompress the landing image
2026-07-06 06:48:28 -07:00
pythongosssss
ccb3cd428b fix: restore the coachmark landing's md max-width
Removing md:max-w-[800px] let the DialogContent variant's sm:max-w-xl
(576px) cap the 800px landing card; tailwind-merge does not dedupe
max-w across breakpoint modifiers.
2026-07-06 05:37:37 -07:00
pythongosssss
9a71b54d05 refactor: move the coachmark tour state machine into a Pinia store
- Convert useCoachmarkTour to onboardingTourStore; replace the
  coachmarkController event hook with a direct store.replayTour() action
- End an active tour (without the seen-flag) when its trigger stops
  holding, e.g. leaving app mode mid-tour
- Toast an error and report skip_reason telemetry (user / target_timeout /
  trigger_lost) when a tour is skipped
- Disable primary buttons while waiting on a deferred target; rename
  suspendFocusGuard to waitingForTarget
- Rename useFocusTrap to useCoachmarkFocusTrap; while suspended, leave
  Tab to the mounting UI but keep the Escape bail-out
- Only auto-open tours on desktop-width (md+) layouts
- Park the registry's rAF poll while no candidate element is registered,
  resuming reactively on registration
- Extract TOUR_SEEN_SETTING and use COACH_IDS constants over string
  literals; move scrim/ring/pulse styles to design-system tokens
- Port tour tests to the store, cover Done in the e2e spec
2026-07-06 03:58:58 -07:00
pythongosssss
61756e977b fix missing modal backdrop 2026-07-03 12:50:35 -07:00
pythongosssss
7630cc9314 fix: use the generic Skip label on the coachmark tour landing
- drop the landing's "Skip for now" override so it falls back to "Skip"
- landing skip already ends the tour and marks it seen (no behavior change)
- unit tests build i18n from the real en locale instead of inline messages
- e2e covers the real auto-open path via a pre-seeded empty seen setting
- trim redundant test comments
2026-07-03 10:52:02 -07:00
pythongosssss
bb39a51d46 fix: narrow the coachmark i18n exemption to derived step keys
- only onboardingCoachmarks.<tour>.<step>.* is dynamically built;
  top-level keys (stepLabel, next, done, skip) stay checked
2026-07-02 06:15:25 -07:00
pythongosssss
76abe8eb3f Merge branch 'main' into pysssss/product-coachmarks 2026-07-02 13:59:01 +01:00
pythongosssss
9bcfda88f6 refactor: reconcile v-coachmark from the element's own data-coach-id
- a single sync() replaces the per-hook register/unregister logic;
  mounted/updated/unmounted all reconcile desired vs current id
- drops the oldValue bookkeeping and its explanatory comment
2026-07-02 05:54:52 -07:00
pythongosssss
e7cdfc8c35 chore: ignore derived coachmark keys in the unused-i18n check
- tour step keys are built as onboardingCoachmarks.<tour>.<name>.*, so
  the literal-key scan can't see them
2026-07-02 05:47:10 -07:00
pythongosssss
e97746fd16 refactor: address coachmark tour PR feedback
- derive step translation keys from a step `name`
  (onboardingCoachmarks.<tour>.<name>.*), with te()-checked
  primary/skip label overrides falling back to Next/Done/Skip
- move all tour i18n into the engine; TourOverlay and TourSpotlight
  now receive translated title/body strings
- replace the landing's required open model with a skip emit and
  drop the landingOpen writable computed
- simplify TourOverlay.test.ts mocks with fromPartial
- replace SCRIM_COLOR with Tailwind classes (bg-black/60,
  spotlight spread shadow)
- narrow the app-run-button anchor so the spotlight excludes the
  run error warning
- key the e2e tour fixture's replay button by tour name
- drop the tour spec's template loading; the test server's default
  workflow already populates the graph (locally: pnpm dev:test)
- prune comments that restated code
2026-07-02 05:39:08 -07:00
pythongosssss
549200a76c fix: address coachmark tour stacking, telemetry, and fixture review notes
- clear the spotlight's previous ZIndex entry before re-raising, so step
  changes stop leaking entries into the shared modal stacking sequence
- report tour telemetry as the user-visible numbering: 1-based step_number
  within counted spotlight steps, omitted for the landing
- seed the e2e seen-tours setting from TOURS so future tours can't
  auto-open under unrelated suites
2026-07-02 01:31:08 -07:00
pythongosssss
96c2ae1182 feat: add landing image to the app-mode tour
- Show public/assets/images/app-mode-landing.png on the welcome landing's
  left panel
2026-07-01 10:19:44 -07:00
pythongosssss
2312b213ce refactor: address coachmark tour review feedback
- Drop the ?coach= query param: remove the forced-entry/replay-any override
  and delayed force-start; tours now start only via auto-open or an explicit
  request. E2E replays via the in-app help button after entering app mode
- Flatten coachmarkController to plain requestTour/onTourRequested exports
  instead of a useCoachmarkController composable
- Derive the top-bar safe inset from the --comfy-topbar-height token plus
  CARD_GAP instead of hardcoding 56
- Remove the landing Start button's fixed width
- Document the real cause of the landing Escape workaround (global keybinding
  preventDefaults Escape before Reka's DismissableLayer dismisses)
- Trim verbose comments in coachmarkRegistry and TourOverlay tests
2026-07-01 10:07:51 -07:00
pythongosssss
ce8b107322 fix: correct coachmark tour skip telemetry and one-shot forcing
- Report the timed-out deferred step in skip telemetry by advancing the
  step index before ending the tour, instead of logging the prior step
- Make the ?coach= override one-shot: named paths force-start only via the
  delayed timer (respecting START_DELAY_MS) and re-entry honors the
  seen-flag again, rather than bypassing it all session
- Resolve waitForTarget to false immediately when the signal is already
  aborted, so it can't hang until timeout
- Reuse the cached dialog locator in Tour.cardForStep
2026-07-01 09:14:15 -07:00
pythongosssss
831813a9db fix: stop coachmark spotlight polling once its target settles
- Drive Floating UI autoUpdate manually so animationFrame polling runs
  only while a deferred target is still moving, then fall back to
  scroll/resize listeners instead of polling every frame for the whole step
- Set aria-modal to false on interaction steps where the user must click
  outside the card
- Ignore unrecognized ?coach= values; keep `any` as the replay keyword
- Align the spotlight scrim with the landing backdrop (0.62 -> 0.6) to
  avoid a dim shift on landing -> spotlight
- Export COACH_IDS and import it in the drift guard instead of a hardcoded
  in-sync list
- Clarify why the landing needs an explicit Escape listener (Reka's
  DismissableLayer doesn't fire update:open here)
- Test that the started telemetry event omits step_index/coach_id while
  per-step events include them

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 07:43:19 -07:00
pythongosssss
64d10da9d7 refactor: place coachmark cards with Floating UI
- Replace the hand-rolled card-placement math (resolvePlacement /
  cardCorner / clampCardPosition) and the manual target tracking (scroll
  listener + useElementBounding + rAF settle loop) with @floating-ui/vue
  (offset/flip/shift + autoUpdate); promote it from a transitive to a
  direct dependency.
- Keep vertically-centred (leftCenter) cards on-screen with
  shift({ crossAxis: true }), and centre the card when its target hasn't
  laid out, so a step never renders off-screen or invisible.
- Make targetMounted/waitForTarget layout-aware (poll per frame) so a
  deferred target that registers before it sizes resolves only once
  measurable, matching what the spotlight actually displays.
- Shrink the spotlight glow (pad 8->4) so it no longer spills onto an
  adjacent control the user might click.
- Label the spotlight dialog with aria-labelledby pointing at its heading
  instead of a duplicated aria-label.
- Drop the unused isActive option from useFocusTrap.
- Add an e2e guard that walks every app-mode spotlight step and asserts
  each card sits fully within the viewport; add unit coverage for
  last-step Skip hiding, modal z-index reclaim, and all four onboarding
  telemetry stages.
2026-07-01 06:20:03 -07:00
pythongosssss
3f84d4f5f2 test: cover forced tour start, button labels, and spotlight escape
Raise useCoachmarkTour.ts to full line coverage and TourSpotlight.vue escape handling with behavioral tests.
2026-07-01 03:45:44 -07:00
pythongosssss
5383e23d24 Merge origin/main into pysssss/product-coachmarks
Conflict in LinearControls.vue: main refactored the run-button test id into a
named constant (linearRunButtonTestId, same 'linear-run-button' value), while
this branch added the v-coachmark anchor and a static test id to the same
sections. Kept the coachmark directive and adopted main's :data-testid binding,
dropping the now-redundant static id.
2026-07-01 02:57:44 -07:00
pythongosssss
9b8dd27f3d refactor: gate coachmark tour listeners behind an active-step component
Extract TourSpotlight, rendered only while a spotlight step is shown, so the
geometry/scroll, focus-trap and click-to-advance listeners (plus z-index and the
stall pulse) register for the active step rather than the whole graph-view
session. useCoachmarkTour slims to the state machine; the registry-query helpers
(targetMounted/waitForTarget) move to coachmarkRegistry and useCoachmarkTarget
becomes geometry-only and self-measuring.

Also from review feedback:
- read the ?coach= force in setup, before the immediate auto-open watcher, so an
  already-populated app no longer drops a ?coach=any replay
- harden isEntryPath against prototype keys (Object.hasOwn)
- decouple each tour's auto-open condition into useTourTriggers, keeping the
  engine tour-agnostic
2026-06-30 14:00:43 -07:00
pythongosssss
a818b7eee8 test: open the template browser in the coachmark drift guard
Trimming the graph-mode anchors earlier removed the templates-button
click that loadTemplate relied on to open the browser; open it via the
Comfy.BrowseTemplates command instead.
2026-06-30 12:48:49 -07:00
pythongosssss
87d0a110cd fix: close the coachmark landing on Escape explicitly
Reka's built-in Escape dismissal proved unreliable for this dialog in
e2e (Skip, which sets open=false directly, works); close via the same
model path on Escape so the welcome landing reliably dismisses.
2026-06-30 12:41:24 -07:00
pythongosssss
b65da23915 refactor: slim coachmark target tracking and spotlight chrome
- Track the spotlight target with VueUse useElementBounding instead of a
  hand-rolled measure/RAF/ResizeObserver loop; keep a capture-phase scroll
  listener so it still follows targets inside scrollable panels
- Fold the spotlight ring into the dim element (CSS outline + box-shadow),
  dropping the separate SVG; the idle pulse now animates outline-color
- Remove the unused CoachStep `elevated` field and its plumbing
2026-06-30 12:07:22 -07:00
pythongosssss
07356e3253 tidy 2026-06-30 11:19:58 -07:00
pythongosssss
51182127f3 feat: auto-open app-mode tour for populated apps
- Open the app-mode tour when entering an app with linear controls
  visible (mode === 'app' && hasOutputs), including when the overlay
  mounts into one already (immediate watch)
- Respect the seen-flag, so it won't reopen once completed or skipped;
  skip the empty/welcome state and arrange (builder) mode
- Move test target DOM cleanup into afterEach so appended nodes are
  removed even if a test fails early
2026-06-30 10:49:42 -07:00
pythongosssss
fdfa9882b1 feat: make app-mode tour explicit-only and refine assets step
- Open the tour only via the help button or ?coach= param (no auto-open
  when entering app mode)
- Drop the loadTemplate step tech and the demo card image — the tour runs
  on the user's existing app
- Skip the assets-button step at tour start when the assets panel is
  already open; step indicator counts 4 instead of 5
2026-06-30 08:27:53 -07:00
pythongosssss
88cd848245 fix test 2026-06-30 04:39:29 -07:00
pythongosssss
5dbb560ef0 refactor + tidy 2026-06-30 04:35:24 -07:00
pythongosssss
f973626ebc improve test coverage 2026-06-30 03:21:59 -07:00
pythongosssss
e9729ca272 drop blankCanvas tour 2026-06-30 02:44:00 -07:00
pythongosssss
c51f963ef2 trim comments 2026-06-30 02:20:03 -07:00
pythongosssss
5e23f76642 fix: address coachmark tour review feedback
- onboardingTours: use 'auto' placement for assets panel so the card follows the sidebar side
- TourOverlay: clamp no-target card left to the viewport margin so it never goes off-screen
- useCoachmarkTour: catch onPrimary action failures, surface a toast, and only advance on success
- useCoachmarkTour: claim the single-instance guard synchronously so a duplicate mount stays inert
- useFocusTrap: send Shift+Tab from outside the trap to the last item instead of skipping it
- telemetry: drop dead run-button imports left over from the rebase (superseded by getRunButtonTelemetryProperties)
2026-06-30 02:02:27 -07:00
pythongosssss
f642384674 change dash stroke to solid 2026-06-30 02:02:27 -07:00
GitHub Action
f80deb9655 [automated] Apply ESLint and Oxfmt fixes 2026-06-29 21:57:11 +00:00
pythongosssss
75af0430fc - change dialog to use standard component
- move tour trigger to linear controls section
2026-06-29 14:48:18 -07:00
pythongosssss
cc74e1dc65 add assets step to app mode tour 2026-06-29 14:48:18 -07:00
pythongosssss
989773995a feat: add product coachmark onboarding tours
- add blank canvas (demo) and app mode (wip) tours
- overlay with target highlight, landing card, step state handling, step card
- v-coachmark directive updating registry for mount/unmount
- frame settling watcher for animated targets (dialogs)
- focus trap for the target plus the coachmark element
- add telemetry for each step
2026-06-29 14:48:18 -07:00
142 changed files with 25334 additions and 1027 deletions

9
.fallowrc.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
"entry": ["src/main.ts", "index.html"],
"duplicates": {
"minOccurrences": 3,
"ignore": ["**/*.generated.*", "**/generatedManagerTypes.ts"]
},
"rules": {}
}

3
.gitattributes vendored
View File

@@ -6,3 +6,6 @@ packages/registry-types/src/comfyRegistryTypes.ts linguist-generated=true
packages/ingest-types/src/types.gen.ts linguist-generated=true
packages/ingest-types/src/zod.gen.ts linguist-generated=true
src/workbench/extensions/manager/types/generatedManagerTypes.ts linguist-generated=true
# Vendored upstream workflow templates used as resolver test fixtures
src/renderer/extensions/firstRunTour/__fixtures__/templates/*.json linguist-generated=true

1
.gitignore vendored
View File

@@ -16,6 +16,7 @@ yarn.lock
.eslintcache
.prettiercache
.stylelintcache
.fallow/
node_modules
.pnpm-store

View File

@@ -88,6 +88,11 @@ const config: StorybookConfig = {
replacement:
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
},
{
find: '@/platform/workspace/composables/useWorkspaceUI',
replacement:
process.cwd() + '/src/storybook/mocks/useWorkspaceUI.ts'
},
{
find: '@/platform/workspace/stores/teamWorkspaceStore',
replacement:

View File

@@ -1,730 +0,0 @@
# Git Branching and Release Strategy
Status: Proposed
Scope: ComfyUI_frontend branching, release management, and environment promotion
Audience: frontend engineers, release rotation, QA, cloud and core release stakeholders
This document does three things:
1. Maps the current branching and release process and its measured pain points.
2. Reviews a draft proposal to replace it with a 4-tier environment-branch model
(testing, dev, staging, main), including an honest scorecard and a
keep-or-reject disposition for every element of that proposal.
3. Specifies the recommended strategy, how it addresses each pain point, and a
phased rollout plan with risks and open questions.
## 1. Executive summary
The current process is not "only main". The repo runs one eternal development
branch plus 55 frozen release branches (38 core lines, core/1.6 through
core/1.47 with gaps; 17 cloud lines, cloud/1.31 through cloud/1.47), a
label-driven cherry-pick backport pipeline,
and a biweekly promotion train into ComfyUI core. The pain is real but it is
not caused by a missing environment hierarchy. It is caused by the distance
between main and the shipped lines: the longer a pinned release line lives,
the more cherry-picks it needs, the harder each one gets, and the bigger and
riskier each release batch becomes.
The draft 4-tier proposal is reviewed in section 4. Verdict: do not adopt as
written (overall 4/10 for this repo), because it models a single-track web
application while this product permanently ships three concurrent version
tracks, and its central promise (eliminating backports) is unachievable while
ComfyUI core pins an exact frontend package version. Eight of its underlying
instincts are correct and are adopted into the recommendation.
The recommended strategy (section 6) is: one eternal branch (main), short-lived
version branches only where a pinned version demands them (core/x.y), pipeline
promotion of build-once artifacts for cloud environments instead of environment
branches, hard freeze-as-code and drift limits, invariant checks instead of
notification bots, and a machine-maintained production pointer that gives git
visibility into what is deployed without human merge ceremony. This direction
aligns with and extends the org's existing shipping-speed initiative rather
than relitigating it.
## 2. Current topology: three concurrent tracks
Any branching strategy for this repo must first model what actually ships.
There is no single production. Three tracks run concurrently, each with its own
consumers, artifact, and patch path:
| Track | Consumers | Artifact | Deploys via | Patch path today |
| --- | --- | --- | --- | --- |
| Nightly | Community users running `--front-end-version @latest` (thousands) | GitHub release built from main | Nightly version-bump PR, tag, release | Fix merges to main, ships next nightly |
| Cloud | cloud.comfy.org users | Static assets built per commit SHA into GCS by the cloud repo | testcloud tracks the active `cloud/x.y` tip; staging and prod promote a SHA pointer via overlays and ArgoCD | Cherry-pick to `cloud/x.y` via backport label, then staged deploy |
| Core GA / desktop | ComfyUI stable and desktop installs | `comfyui-frontend-package` wheel on PyPI, pinned exactly in ComfyUI `requirements.txt` | Biweekly train: patch bump on `core/x.y`, PyPI publish, pin-bump PR on ComfyUI | Cherry-pick to `core/x.y`, patch release, new pin PR |
As of mid July 2026: main is at 1.48.x, cloud runs the 1.47 line, and the
ComfyUI pin is still on 1.45.x. Three minors of distance between main and core
GA is the normal operating state, not an anomaly.
```mermaid
flowchart LR
FIX[Fix merges to main] --> NB[Nightly bump and tag] --> NU[Nightly users]
FIX --> CLB[Cherry-pick onto cloud line] --> STG[Staging deploy] --> PRD[Prod deploy] --> CU[Cloud users]
FIX --> COB[Cherry-pick onto core line] --> PYP[PyPI patch release] --> PIN[ComfyUI pin update] --> DU[Core and desktop users]
```
Key mechanics worth naming because the strategy must preserve or deliberately
replace each one:
- Every minor bump on main automatically freezes the previous minor into paired
`core/x.y` and `cloud/x.y` branches and rotates the matching backport labels.
- Backports are label-driven: `needs-backport` plus a target label triggers an
automated cherry-pick PR; conflicts fall back to a documented manual path.
- The cloud deploy already promotes build-once artifacts: assets are built one
time per SHA, and staging and prod move by repointing that SHA. Rollback is a
pointer revert with no rebuild.
- Core GA promotion is a biweekly scheduled train that publishes to PyPI and
drafts the ComfyUI pin-bump PR.
- A rotating release owner (the "sheriff") drives promotions; per-PR backport
ownership was deliberately moved to feature pods in June 2026.
- Frontend builds destined for core GA soak in the core nightly channel
(about two weeks for graph-touching changes) because there is no automated
signal when a change breaks a community custom node. The soak is a
compensating control for missing telemetry, not a property of safe code.
These mechanics are documented operationally in `docs/release-process.md`,
which remains the runbook of record for day-to-day releases. This document
governs the target strategy, and that runbook gets updated as each phase in
section 8 lands.
## 3. Pain points of the current process
Numbered for traceability to section 7. All are observed, not hypothetical.
- **PP1. Drift compounds on long-lived lines.** The 1.45 line spans 69 days
from its minor cut to its latest patch and is still the pinned line: 19
patch releases, 61 commits landed on it after it froze (54 backport
cherry-picks plus 7 patch-release bumps), and main-vs-stable divergence of
428 PRs by the latest patch. Backports onto old lines increasingly do not
apply cleanly, and some do not even work once applied because the
surrounding code diverged (a fix was backported to a GA line where it could
not function without a second dependency PR, and was ultimately abandoned
as a known issue).
- **PP2. Per-PR cherry-picks can silently miss a target.** A fix that needed
two cloud lines landed on only one; the next release shipped the regression
back to users. Three independent safeguards (notification bot, sweep check,
PR comment) all failed to catch it (FE-713).
- **PP3. Release batches grow superlinearly risky.** Missing one train window
meant a double-minor release: 401 PRs validated in a single QA pass, the
largest surface the team has ever had to certify, including high-risk
subsystem rewrites.
- **PP4. The backport tooling itself fails silently.** The notification bot was
down for six weeks without anyone noticing; the repo-level auto-merge setting
is off, which silently turned the backport auto-merge flag into a no-op and
required a workaround workflow; the manual retry path of the backport
workflow was broken (FE-1282).
- **PP5. Freeze state lives in conversation, not tooling.** A verbally frozen
line was bumped past by an engineer who reasonably read stalled automation as
a missed dispatch. The resulting premature minor cut cost one to two weeks of
recovery. The post-mortem's first action item: encode freeze state durably.
- **PP6. Human bottlenecks and burnout.** One engineer ran the release rotation
for roughly two months straight; urgent core releases have required chasing
approvals late at night. Backport PRs add review friction (authors cannot
always self-approve, approvers are asleep, label permissions vary).
- **PP7. Latency invites bypasses.** A simple graph change takes a minimum of
about 16 days to reach core GA (review, two-week soak, release), up to 27 if
it just misses a window. Under deadline pressure a customer demo was shipped
via a one-off deployment that routed around the release process entirely,
creating an unowned production surface.
- **PP8. Release state is hard to read.** Answering "what exactly is on cloud
prod" requires cross-referencing a branch tip, a deploy tag, and a SHA in
another repo's values file. Public release surfaces (GitHub releases, docs
changelog, in-app update notice) have drifted out of sync.
- **PP9. QA involvement is ad hoc.** Test plans are hand-built per release
(one release needed a bespoke plan naming 84 high-risk PRs); there is no
standing definition of entry and exit criteria per promotion gate.
- **PP10. Everything above burns cross-team trust.** Slow stable releases are
a recurring source of cross-team friction, and the release rotation absorbs
that pressure personally.
## 4. Review of the draft 4-tier proposal
### 4.1 The proposed model
The draft proposes four long-lived branches, each auto-deploying to its own
standing environment, promoted wholesale (no cherry-picks) by a release
manager, with hotfixes cut from main re-entering through staging, and a
main-to-testing back-merge closing each cycle:
```mermaid
flowchart LR
T[testing branch and env] -->|release manager merge| D[dev branch and env]
D -->|release manager merge| S[staging branch and env]
S -->|release manager merge| M[main branch equals prod]
M -.->|back-merge at cycle end| T
M -->|urgent defect| H[hotfix branch] --> S
```
Claimed properties: engineers merge freely into testing; dev is a guaranteed
stable baseline; staging mirrors production for UAT; main is 1-to-1 with what
is deployed, "eliminating the complicated and error-prone process of
back-porting entirely."
This model was reviewed against the org context in sections 2 and 3, the
industry evidence in section 5, and an adversarial defense pass (a reviewer
whose explicit job was to defend the proposal and refute the critique; several
initial critique points were withdrawn or narrowed as a result and are noted
below).
### 4.2 What the proposal gets right
These instincts are correct, and each is adopted in section 6 via a cheaper
mechanism than a branch tier:
- **K1. Deployed state should be inspectable in git.** Today it is not (PP8).
Adopted as a machine-maintained prod pointer (6.5).
- **K2. Freezes must be enforced by tooling, not by verbal agreement.** The
premature-bump incident (PP5) proves it. Adopted as freeze-as-code (6.6).
- **K3. Promotions need named owners and explicit gates.** Adopted via GitHub
Environments protection rules and the existing rotation (6.4).
- **K4. Engineers need a guaranteed-good base to branch from when trunk is
red.** Adopted as last-green stable tags on main (6.7).
- **K5. Fix propagation needs a hard discipline.** The draft's down-merge SLA
becomes the inverse and industry-standard rule: upstream first, fix lands on
main before any release line (6.3).
- **K6. Whole-unit promotion beats per-PR picking wherever a track allows it.**
Wholesale promotion makes the PP2 failure class (a missed per-PR pick)
unrepresentable. Adopted for the cloud track as whole-artifact promotion,
and bounded on the core track by a drift SLO (6.8).
- **K7. Every promotion should auto-deploy its surface.** Directionally right
and already sanctioned by in-flight work that removes no-value manual steps
(FE-1176). Adopted throughout.
- **K8. QA deserves a first-class, named slot in the release path.** The only
document in this debate that gives QA an explicit stage. Adopted as defined
entry/exit criteria per gate instead of a dedicated environment (6.4).
### 4.3 Findings
Ordered by severity. "Withdrawn" notes mark initial critiques that did not
survive the adversarial defense, kept here so the review is honest about its
own error bars.
**Fatal (any one of these blocks adoption as written):**
- **F1. The central claim is false for this product.** "Eliminates
back-porting entirely" cannot hold while ComfyUI core pins an exact
`comfyui-frontend-package` version and desktop users run pinned installs.
A linear four-tier chain holds exactly one version in flight; it has no
mechanism to patch a shipped 1.45 while 1.47 is mid-promotion and 1.48 is
on nightly. Fixes to pinned lines remain cherry-picks plus PyPI patch
releases, that is, backports. The proposal eliminates the word by omitting
the surface that needs it: nowhere does it mention the pin, PyPI, desktop,
the soak, or custom nodes. (Narrowed but confirmed under defense: wholesale
trains do reduce drift-driven backport volume; they do not remove the
pinned-version axis.)
- **F2. It re-models an existing artifact pipeline as merge ceremony, and
regresses it.** Cloud promotion is already build-once: one SHA-keyed asset
build, promoted by pointer, rollback with no rebuild. Branch-tier CD means
each tier builds its own merge commit, so the artifact validated on staging
is provably not the artifact deployed from main. Worse, GitHub pull request
merges never fast-forward, so gated promotion PRs mint a new SHA at every
tier: "human-gated promotion" and "main is SHA-identical to prod" are
mutually exclusive with native GitHub mechanics. One of the model's two
core promises must break.
- **F3. The soak has no home.** The two-week custom-node soak is the org's
central regression control for its worst historical failure class. In a
linear chain it either occupies the staging tier permanently (capping all
release cadence at soak length and colliding with the hotfix path) or
silently disappears while its automated replacement remains unstaffed.
- **F4. It rows against the org's sanctioned direction.** The shipping-speed
initiative targets PR-to-prod under 48 hours, backport rate under 10
percent, and stable tags cut from main every 24 to 48 hours, backed by an
internal PRD and in-flight Linear work (FE-1176, FE-602/BE-800, release
gate automation). The draft adds an N-day merge freeze (at roughly 330
merged PRs per month across all targets, about 230 of them on main, that
is a real stall), three human promotion gates,
and full-cycle latency for every change. DORA's trunk research lists "no
code freezes" as a success criterion for elite delivery.
**Major:**
- **F5. The hotfix path is inoperable mid-cycle.** Hotfixes cut from main
re-enter through staging. Whenever staging holds next-cycle content in UAT
(most of the calendar), an urgent prod fix either waits out UAT or drags
unreleased work to production. At the observed fix rate on live lines
(54 backports in the 53 days after the 1.45 line froze, about one per
day), "restart UAT on every hotfix" is a validation livelock. Notably, both parents of this model do it differently:
GitLab Flow is strictly upstream-first, and GitFlow merges hotfixes to
master directly. The draft inherits the weaker property of each.
- **F6. "Guaranteed stable dev" is a smoke test, not a guarantee.** dev
receives testing wholesale, so at promotion time dev is byte-identical to
testing at freeze. The only stability delta is whatever a quick smoke pass
catches, and the tier's only unique content comes from direct-to-dev
hotfixes, which is the environment-drift anti-pattern. A second standing
environment running identical code adds configuration-drift false positives,
not signal. (Narrowed under defense: as a lagged checkpoint, dev does have
value mid-cycle; but a last-green tag delivers the same checkpoint without
a branch, an environment, or a gate.)
- **F7. Every gate is human and every suite is undefined.** The model's
stability claims rest entirely on "smoke tests," "all regression and
acceptance tests," and release-manager judgment. No suite, owner, or pass
criterion is named anywhere. Against roughly 330 PRs per month with a
two-person QA function, undefined manual gates become either the bottleneck
or a rubber stamp; the 401-PR pass (PP3) becomes the steady state, since
biweekly wholesale promotion at current velocity is roughly 110 to 165 PRs
per batch (main-line content alone runs about 110 per two weeks).
- **F8. No rollback story, and env-branch rollback poisons future
promotions.** The draft never mentions rollback. Reverting a bad promotion
merge makes git treat that content as already-merged, silently dropping it
from the next wholesale promotion until someone reverts the revert. And the
moment a prod deploy fails after the staging-to-main merge, main is ahead of
prod again, the exact state the model claims to abolish.
- **F9. The four auto-deploy pipelines do not exist and cannot be driven from
this repo.** This repo never deploys anything; it fire-and-forgets a
dispatch to the cloud repo, which owns builds, secrets, and ArgoCD. Standing
up per-branch CD means building four cross-repo deploy paths while the
backend concurrently moves to per-service delivery with its own promotion
model. The proposal is also purely additive: since it cannot serve the
pinned-package axis (F1), all existing release machinery keeps running
beside it, and the net delta is plus three eternal branches, plus one or two
standing environments, plus three human gates.
**Spec gaps and smaller issues:**
- **F10. Wholesale-minus-exceptions needs a written procedure.** Excluding one
bad PR from a promotion is revert-then-reland (and revert-the-revert later),
a known git footgun that will otherwise be improvised for the first time
during a release emergency. The draft's own exception clause re-authorizes
the cherry-picking it bans. (Initial claim "this is not a git operation" was
withdrawn: revert/reland is standard practice; the finding is that the
procedure is unwritten and its failure mode, silently dropped features, has
no detecting check.)
- **F11. Freeze semantics are underspecified rather than contradictory.**
(Initial "rules 1.c and 1.d contradict" was withdrawn.) The real gap:
stabilization fixes must merge during the freeze, so the freeze is porous by
design, with no tooling-enforced definition of what may enter, which is
exactly the un-encoded-freeze failure mode of PP5.
- **F12. Naming and contract frictions.** A tier named dev that is more stable
than testing inverts every industry convention; repointing main's semantics
would break the nightly channel contract, though the model does not actually
require the top tier to be named main (withdrawn as a fatal objection: tier
names are free variables; kept as a migration note). Rule 6.a's ancestry
policy has no native GitHub primitive and needs a small custom status check;
cheap, but this org's post-mortems document exactly this class of bespoke
automation failing silently (PP4).
### 4.4 Scorecard
| Dimension | Score | Basis |
| --- | --- | --- |
| Internal coherence | 5/10 | F5, F8, F10, F11 are real; several initial coherence critiques were withdrawn under defense |
| Fit to this product's topology | 3/10 | F1, F3: the pinned-package axis and the soak are unmodeled |
| Operability on GitHub with current infra | 2/10 | F2 SHA exclusivity, F9 pipelines do not exist, additive process surface |
| Alignment with current industry evidence | 3/10 | Section 5; env-branch promotion is a documented anti-pattern for continuously deployed surfaces, with narrow exceptions that map to pipeline approval gates |
| Quality of underlying instincts | 8/10 | K1 through K8 are correct and adopted |
| **Overall, as written, for this repo** | **4/10** | Fatal findings F1 through F4 |
For fairness: as a generic process for a single-track web application in the
era it comes from, this model rates roughly 6.5/10; GitLab Flow's environment
branch variant, which it closely resembles, remains documented practice. The
low score here is about fit to this product, not about the model's pedigree.
### 4.5 Disposition of each proposal element
| Proposal element | Disposition | Why |
| --- | --- | --- |
| Four long-lived environment branches | Reject | F1, F2, F3, F9; environments become pipeline stages instead |
| Wholesale promotion, no cherry-picks | Adapt | Correct for cloud as whole-artifact promotion (K6); impossible for the pinned core axis (F1) |
| Release-manager gated merges at three tiers | Adapt | One named human gate per surface via GitHub Environments required reviewers; no new role hierarchy (PP6) |
| N-day code freeze on the integration branch | Reject | F4; freezes are the documented anti-pattern and main never freezes today |
| Anything in testing ships in the current cycle | Adapt | Correct WIP-limit instinct; becomes the enforceable drift SLO in 6.8 |
| dev tier as stable baseline | Replace | Last-green stable tags on main (K4, F6) |
| staging tier for UAT and alpha access | Keep the capability | Already exists as the staging environment plus auth-gated and per-PR preview deploys; formalize QA criteria (K8) |
| main equals prod, 1-to-1 | Replace | Machine-maintained prod pointer ref (K1); merge-based parity is unenforceable on GitHub (F2) |
| Hotfix via branch from main through staging | Reject | F5; hotfix path per track defined in 6.9 |
| Back-merge main to testing at cycle end | Reject | Unnecessary once there is a single eternal branch; upstream-first makes down-merges structural (K5) |
| GitHub-enforced containment policy (6.a) | Adopt the idea | As a 20-line ancestry status check plus release-content verification (6.8), with heartbeat alerts given PP4 |
| Version tag on every prod deploy | Already exists | Cloud deploy tags and nightly tags; kept and unified in the prod pointer spec |
## 5. What the industry does
Genealogy first, because the draft cites a remembered decade-old post with
detailed charts. No post titled "a better git branching strategy" from that
era appears to exist. The remembered title and charts almost certainly belong
to Vincent Driessen's "A successful Git branching model" (nvie.com, 2010, the
GitFlow post). The remembered content, four auto-deploying environment tiers
promoted by wholesale merge, matches GitLab Flow's environment-branches
variant (2014). The hotfix-from-prod and back-merge mechanics are GitFlow's.
The draft is therefore a hybrid of two models from 2010 to 2014, both of whose
authors have since published significant caveats:
- Driessen added a note to the GitFlow post in 2020: teams shipping
continuously delivered web software should use a much simpler flow such as
GitHub Flow; GitFlow-style models remain reasonable for explicitly versioned
software with multiple versions in the wild. Both halves apply here, because
this product is both.
- GitLab's own docs now describe the environment-branch variant alongside a
release-branch variant with an explicit upstream-first rule (fix on main,
cherry-pick down), the same policy Google and Red Hat practice.
The current consensus, by product shape:
- **Continuously deployed services** (GitHub, Shopify, Google, the DORA
research corpus): one mainline, short-lived topic branches, merge queue,
feature flags, canary or percentage rollout. Environments are deployment
pipeline stages or GitOps folders, not branches. DORA's trunk criteria:
three or fewer active branches, daily merges, no code freezes; elite
performers correlate strongly with this shape. Environment-branch promotion
is repeatedly documented as an anti-pattern (Fowler's branching patterns;
the GitOps literature: merge-order skew, unintended config riding along,
undocumented prod drift from direct-to-branch hotfixes, per-branch rebuilds
violating build-once). The one conceded exception, regulated sign-off audit
trails, is satisfied by pipeline approval gates with deployment history.
- **Versioned or embedded software** (Chrome, Firefox, Microsoft Release
Flow): trunk plus short-lived per-release branches, cut just in time,
fix-only, upstream-first cherry-picks with approval, never merged back,
retired when the version leaves support. Chrome ships a milestone every four
weeks this way; Firefox calls the cherry-pick an uplift and gates it on
release management approval. The heavyweight tooling both maintain is
evidence that backports are a cost to be minimized, not a routine channel.
- **Hybrid products like this one** split the axes: trunk plus pipeline
promotion and flags for the continuously deployed surface; trunk plus
short-lived release branches for the pinned, versioned surface. That is
Microsoft Release Flow on one side and Chrome-style trains on the other,
sharing one trunk.
Sources: nvie.com/posts/a-successful-git-branching-model,
about.gitlab.com/topics/version-control/what-is-gitlab-flow,
dora.dev/capabilities/trunk-based-development,
martinfowler.com/articles/branching-patterns.html,
trunkbaseddevelopment.com/branch-for-release,
octopus.com/blog/stop-using-branches-deploying-different-gitops-environments,
beyond.minimumcd.org/docs/reference/practices/immutable-artifacts,
devblogs.microsoft.com/devops/release-flow-how-we-do-branching-on-the-vsts-team,
chromium.googlesource.com/chromium/src/+/master/docs/process/release_cycle.md,
wiki.mozilla.org/Release_Management/Release_Process,
github.blog/engineering/engineering-principles/deploying-branches-to-github-com,
shopify.engineering/successfully-merging-work-1000-developers.
## 6. Recommended strategy
### 6.1 Principles
- **P1. One eternal branch.** main is the single integration branch and the
nightly channel. Its semantics are a locked contract: nightly releases,
npm type publishing, cloud build dispatch, CI triggers, and community
tooling all key off it.
- **P2. Build once, promote artifacts.** Every merge to main produces one
SHA-keyed artifact. Environments receive that artifact by pointer; nothing
is ever rebuilt per environment. The artifact that passes staging and canary
is byte-identical to the artifact in production.
- **P3. Upstream first, always.** Every fix lands on main first, with tests.
Release lines receive changes only via the backport pipeline. Direct commits
to `core/*` and `cloud/*` are blocked for humans (release automation
excepted). This is the rule that prevents the PP2 class.
- **P4. Branches only where a version demands one.** A long-lived branch
exists only to serve a pinned, shipped version (`core/x.y` while ComfyUI
pins it). No branch exists to represent an environment.
- **P5. Invariants over notifications.** Every policy in this document that
matters is enforced by a required check or a reconciling audit with
heartbeat alerting, not by a bot that posts a message. PP4 is the reason.
- **P6. Gates are automation-first, with at most one named human approval per
surface,** held by the existing release rotation. No new role hierarchy.
- **P7. Small batches on a fixed cadence.** The unit of release stays as small
as the gates allow. The 401-PR batch is the documented anti-goal.
### 6.2 Branch roles
| Branch | Lifetime | Purpose | Who writes to it |
| --- | --- | --- | --- |
| `main` | Eternal | Integration, nightly channel, source of all builds | Engineers via peer-approved PRs |
| `core/x.y` | Weeks; dies when the ComfyUI pin moves off x.y | Serve the pinned PyPI line | Backport automation and release bumps only |
| `cloud/x.y` | Interim only; retired per 6.10 | Cloud release train until cloud CD lands | Backport automation only |
| `feature/*`, `fix/*` | Days | Topic branches off main (or a last-green tag when main is red) | The author |
| Prod pointer ref (`deployed/cloud-prod`) | Eternal, machine-written | Mirrors the verified deployed SHA after every prod sync, rollbacks included | Deploy pipeline only |
Target state, Phase 2 and later (until the cloud branch retirement in 6.10
lands, the cloud leg still promotes from the interim cloud/x.y line):
```mermaid
flowchart LR
ENG[Engineer merges PR to main] --> BUILD[One artifact built per SHA]
BUILD --> NIGHT[Nightly tag and release]
BUILD --> TC[testcloud] --> SC[staging] --> CAN[Canary percent rollout] --> PROD[Cloud prod]
PROD --> PTR[Prod pointer ref advances]
ENG --> GATE[Overnight release gate CI] --> TAGS[Last-green stable tag]
TAGS -->|next minor cut| LINE[core release line, backports only] --> TRAIN[Biweekly core train: patch bump, PyPI, pin PR]
```
### 6.3 The two release axes, explicitly separated
- **Cloud axis (continuous):** main to testcloud to staging to prod is
artifact promotion through pipeline stages. Gates live in the pipeline: the
overnight release-gate suite for candidate selection, a staging smoke
checklist with named QA criteria, and canary metrics with automatic
rollback once the canary program lands. Incomplete features land on main
dark behind feature flags so the trunk stays releasable while work is in
progress. No environment branches.
- **Core axis (versioned):** `core/x.y` branches exist because an exact
version is pinned by another product and shipped to desktops. They are cut
automatically (as today; in the target state the cut is taken from the
newest last-green tag rather than the raw pre-bump commit), receive fixes
only via upstream-first backports, and are retired when the pin moves. The fix for PP1 is not a new topology;
it is shortening how long these lines live and how far they drift (6.8).
### 6.4 Promotion gates
Implemented as GitHub Environments deployment protection rules (required
reviewers plus deployment history), not as branch merges. This natively
provides the audit trail that is the one legitimate case for human-gated
promotion, and it is the same primitive the draft's release-manager gates
actually wanted.
| Gate | Trigger | Automated criteria | Human |
| --- | --- | --- | --- |
| PR into main | Every PR | Unit, component, lint, typecheck | One peer approval |
| Release-gate verdict | Nightly | Behavioral suite, critical-path browser tests, custom-node harness when live | None; red verdict is an incident |
| Staging promotion | On green candidate | Artifact exists, gate verdict green | None (auto) |
| Prod promotion | Sheriff action | Staging smoke checklist green | One: rotation owner |
| Core GA train | Biweekly schedule | Line is green, soak or harness criteria met | One: pin PR merge in ComfyUI |
QA's slot (K8): the staging smoke checklist and the release regression scope
are standing documents with named QA owners and defined entry and exit
criteria, replacing hand-built per-release plans (PP9).
### 6.5 Production visibility (adopts K1)
A machine-maintained ref, `deployed/cloud-prod`, is written by the deploy
pipeline only after the production sync reports healthy, and it mirrors the
deployed SHA in both directions: a verified rollback moves the ref backward
to the rolled-back SHA (a recorded, non-fast-forward move with the rollback
reason logged, which the reconciler treats as healthy), so the ref never lies
about production during an incident. A reconciling check compares the ref against the cloud repo's
deployed SHA on every deploy and alerts on mismatch (P5). Answering "what is on prod" becomes
`git log deployed/cloud-prod`, which is the legitimate requirement behind the
draft's main-equals-prod rule, delivered without merge ceremony and immune to
the F2 impossibility.
### 6.6 Freeze-as-code (adopts K2)
Freeze state is a first-class marker (a repo variable or protected file) that
release automation checks before acting: the minor-bump dispatch refuses to
cut past a frozen line, and backport targeting warns on frozen targets. All
release bots carry heartbeat alerts; silence is an incident. These are the
premature-bump post-mortem action items, promoted into the strategy.
### 6.7 Stable base for engineers (adopts K4)
The nightly release-gate verdict stamps an immutable dated tag (for example
`stable/2026-07-18`) on the newest commit that passed the full gate and
advances a `stable/latest-green` branch-style ref to it. A moving ref, not a
moving tag, on purpose: git clients do not force-update moved tags by
default, so a moving tag would silently go stale locally. When main is red,
engineers branch from the ref or the dated tag instead of a standing dev
branch. Red main is itself an incident
with a named owner, which is what actually keeps the trunk usable.
### 6.8 Drift limits and content verification (adopts K6 and the containment idea)
- **Drift SLO:** the pinned release line being older than 28 days, or the pin
sitting more than two minors behind main, pages the rotation owner and
forces a train decision. This is the WIP limit that the draft expressed as
"anything in testing ships this cycle," made enforceable. PP1's 69-day line
becomes structurally impossible to reach silently. A breach also blocks the
next minor cut until acknowledged. The SLO activates with a burn-in
exemption for the already-breached 1.45 line, and a breach that traces to
an unmerged ComfyUI pin PR escalates per risk R2 instead of paging the
rotation, because that lever is not frontend-side.
- **Convergence invariant:** a scheduled check asserts every commit on a live
release line is an ancestor of main or arrived via the backport pipeline,
and that every prod-deployed SHA is reachable from a release line. Roughly
20 lines of CI; catches what three notification bots missed (PP2).
- **Release-content verification:** before a train departs, an automated
check confirms every PR labeled for that line actually landed on it, and
posts the diff of intent versus content. FE-713 becomes a failing check
instead of a user report.
### 6.9 Hotfix and rollback runbooks
Interim topology (while cloud/x.y lines exist; after the 6.10 retirement the
cloud leg promotes last-green artifacts from main instead):
```mermaid
flowchart TD
Q1{Where is the defect live?} -->|Nightly only| A1[Fix on main, ships next nightly]
Q1 -->|Cloud prod| A2[Fix on main with tests] --> A3[Backport label to the live cloud line] --> A4[Staging smoke on the patched artifact] --> A5[Prod promotion by rotation owner]
Q1 -->|Core GA or desktop| A6[Fix on main with tests] --> A7[Backport label to the pinned core line] --> A8[Patch release to PyPI] --> A9[Pin bump PR on ComfyUI]
Q1 -->|Deploy itself is bad, code is fine| R1[Rollback: repoint prod to previous SHA, no rebuild]
```
Rules: hotfixes never restart full UAT and never bundle unreleased content;
validation scope is the affected area plus the standing smoke checklist. The
sanctioned path must stay hours-scale (target: under 8 working hours from
fix merged on main to prod promotion), because the org has already
demonstrated that a slower sanctioned path manufactures shadow deploy surfaces
(PP7). Rollback is always a pointer move, never a git revert of a promotion;
revert is reserved for code defects on main with an explicit reland step.
### 6.10 Cloud branch retirement (the end state for the cloud axis)
`cloud/x.y` branches exist today because cloud deploys from a frozen line.
Once the release-gate suite plus canary auto-rollback are proven (several
clean ramps and at least one real auto-rollback in production), cloud promotes
last-green artifacts from main directly and the cloud branch family retires.
The cutover checklist must cover: release-branch creation stops cutting
`cloud/<minor>` and rotating cloud labels, the cloud deploy tag workflow
retires, build dispatch keys on main SHAs and promotion events, and testcloud
repoints from the cloud line tip to last-green tags. Until then, cloud/x.y
continues exactly as today; this document changes nothing about it yet.
## 7. How this solves the current pain points
| Pain point | Mechanism in this strategy | Status |
| --- | --- | --- |
| PP1 drift on long lines | Drift SLO (6.8) caps line age; shorter trains; upstream-first keeps lines fix-only | Mitigated; fully solved only if ComfyUI adopts pin bumps at train cadence (risk R2) |
| PP2 missed per-PR backports | Release-content verification plus convergence invariant (6.8); whole-artifact promotion on cloud removes per-PR picks there entirely | Solved: becomes a failing check, and unrepresentable on the cloud axis post 6.10 |
| PP3 giant QA batches | Fixed cadence with drift SLO forces small trains (P7); release-gate CI carries per-merge burden | Mitigated; batch size cannot silently grow past the SLO, and drops under 100 if the 13.1 decision moves the core train to weekly |
| PP4 silent tooling failure | Invariants and reconciling audits with heartbeat alerts replace notification-only bots (P5); prerequisite fixes named in 8 | Mitigated; bots can still break, but silence itself now alarms |
| PP5 verbal freezes | Freeze-as-code guards the bump and backport paths (6.6) | Solved: the premature-bump incident becomes mechanically impossible |
| PP6 human bottleneck | One human gate per surface on the existing rotation; merge-on-green everywhere else; backport approval rules simplified by making automation the only writer to release lines | Mitigated; the rotation remains, its late-night surface shrinks |
| PP7 latency invites bypasses | Hours-scale sanctioned hotfix lane (6.9); canary replaces calendar soak for cloud; core latency shrinks as the harness replaces the soak | Partially solved now, fully contingent on the custom-node harness (risk R1) |
| PP8 unreadable release state | Prod pointer ref (6.5); unified tags; one strategy document | Solved |
| PP9 ad hoc QA | Standing gate criteria with named QA owners (6.4) | Solved by process definition |
| PP10 cross-team trust | Publish the delivery metrics (9) on a fixed cadence so progress is legible instead of argued | Mitigated; trust follows the numbers |
The honest line on backports: this strategy does not eliminate them, because
nothing can while another product pins an exact version. It makes them rare
(drift SLO plus faster trains), safe (upstream-first plus content
verification), and boring (automation is the only writer to release lines).
The draft's promise was elimination; the achievable promise is a backport
rate under 10 percent with no silent misses.
## 8. Rollout plan
Prerequisites (fix before anything else changes): repair the manual backport
retry workflow (FE-1282), turn on the repo auto-merge setting and retire the
cron workaround, add heartbeat alerts to every release bot, and correct the
stale PyPI-attribution in docs/release-process.md so the runbook of record
matches the actual workflows.
- **Phase 0, immediately:** adopt this document; freeze-as-code; prod pointer
ref; drift SLO alerting; convergence invariant check; standing QA gate
criteria drafted (their operational acceptance is P1 item 9). No branch
topology changes at all.
- **Phase 1, with the release gate:** overnight release-gate suite produces
last-green tags; stable tags become the engineer base and the cloud
candidate source; staging promotion goes automatic on green.
- **Phase 2, with the canary:** percentage rollout with metric-gated
auto-ramp and auto-rollback on cloud prod; hotfix lane switches to
canary-validated promotion.
- **Phase 3, retirement:** cloud/x.y family retires per 6.10. Core/x.y
remains, on shorter trains, as long as ComfyUI pins exact versions; the
calendar soak retires only when the custom-node harness is live, staffed,
and has held the escaped-regression guardrail flat for two full cycles.
## 9. Goals and success metrics
| Goal | Metric | Target | Horizon |
| --- | --- | --- | --- |
| Ship fast | PR merge to cloud prod | Under 48 hours median | Phase 2 |
| Backports rare | Backported PRs / merged PRs | Under 10 percent | Phase 2 |
| Lines stay young | Max live release-line age | Under 28 days | Phase 1 |
| No silent misses | Escaped regressions from missed backports | Zero | Phase 0 |
| Batches stay small | PRs per QA-certified release | Under 100 | Phase 2, after the 13.1 cadence decision |
| Prod is legible | Time to answer "what is on prod" | One git command | Phase 0 |
| No shadow deploys | Unowned one-off production surfaces | Zero new | Phase 0 |
Leading indicators are the first three; report all seven on a fixed cadence in
the cross-team channel (PP10).
## 10. Non-goals
- Renaming or repointing main. Its contract is locked (P1).
- Eliminating release branches while ComfyUI pins exact frontend versions.
That coupling is a product decision owned elsewhere; this strategy minimizes
its cost rather than pretending it away.
- Standing up new long-lived environments. Existing surfaces (testcloud,
staging, per-PR previews) cover every tier the draft wanted.
- Changing ComfyUI core's own release process, cadence, or the soak policy it
requires; this document only defines what the frontend does on each axis.
- Prescribing the backend or cloud repo's deployment architecture (per-service
delivery work proceeds independently).
## 11. Requirements
**P0 (the strategy is not adopted without these):**
1. Freeze-as-code marker checked by version-bump and backport automation.
Acceptance: a bump dispatch against a frozen line fails with a clear error;
verified by test.
2. Prod pointer ref written only by the deploy pipeline after verified sync,
with a reconciling audit. Acceptance: mismatch alarms within one deploy
cycle.
3. Convergence invariant and release-content verification checks, with
heartbeat alerting. Acceptance: a deliberately mislabeled test PR is caught
before a train departs.
4. Drift SLO alerting at 28 days of line age or two minors of pin divergence.
Acceptance: alert fires in a rehearsal against a stale line.
5. Documented hotfix and rollback runbooks (6.9) with an hours-scale SLA.
Acceptance: one rehearsed hotfix per quarter meets the SLA.
6. Prerequisite tooling fixes: FE-1282, auto-merge setting, bot heartbeats.
Acceptance: a manually dispatched backport retry succeeds end to end; a
backport PR merges via repo auto-merge with the cron workaround retired;
killing a release bot raises its heartbeat alert.
**P1 (fast follows):**
7. Overnight release-gate suite stamping last-green tags; red verdict is an
incident with a named owner. Acceptance: a green run stamps the dated tag
within an hour; a red run opens an owned incident.
8. Automatic staging promotion on green; GitHub Environments protection rules
with deployment history as the prod gate. Acceptance: a green candidate
reaches staging with zero human actions, and a prod promotion without the
required reviewer is blocked in rehearsal.
9. Standing QA gate criteria with named owners replacing per-release plans.
Acceptance: the next release runs from the standing criteria with no
bespoke test plan authored.
**P2 (future, explicitly sequenced):**
10. Canary with metric-gated auto-ramp and auto-rollback. Acceptance: one
production auto-rollback triggered by canary metrics in a game day.
11. cloud/x.y retirement per the 6.10 checklist. Acceptance: the cutover
checklist fully executed, verified by a workflow inventory showing no
half-dead machinery.
12. Soak retirement gated on the custom-node harness guardrail. Acceptance:
the harness holds the escaped-regression guardrail flat for two full
cycles before any soak shortening.
## 12. Risk register
| ID | Risk | L | I | Level | Mitigation | Owner |
| --- | --- | --- | --- | --- | --- | --- |
| R1 | Custom-node harness stays unstaffed, so soak cannot shrink and core latency persists | High | High | Critical | Escalate staffing as the single gating dependency of the whole program; do not shorten the soak before the harness holds the guardrail | Eng leadership |
| R2 | ComfyUI pin adoption lags frontend trains, so drift SLO breaches are unresolvable frontend-side | High | High | Critical | Agree a pin-adoption SLA with core release stakeholders; SLO breaches tracing to an unmerged pin PR escalate to leadership instead of paging the rotation | FE eng manager with core stakeholders |
| R3 | Canary program slips; cloud branches linger in a half-retired state | Med | High | High | Retirement is the last step with explicit cutover criteria; interim state is exactly today's process | Release pipeline owner |
| R4 | Prod pointer or invariant checks silently break, recreating PP4 | Med | High | High | Heartbeat alerts plus reconciliation against the deploy system on every run; silence pages | DevOps |
| R5 | Drift SLO is ignored under deadline pressure | Med | Med | Medium | SLO breach pages the rotation and blocks the next minor cut until acknowledged | Rotation owner |
| R6 | Incremental rollout gives no visible "fixed" moment; cross-team pressure continues | Med | Med | Medium | Publish the section 9 metrics on a fixed cadence; make progress legible | FE eng manager |
| R7 | Flag debt accumulates as flags gate incomplete work on trunk | Med | Low | Low | Review-for-deletion date on every flag; monthly cleanup | FE leads |
Doing nothing carries its own critical risks (drift compounds, the next
missed backport ships another regression, rotation burnout continues); the
status quo is not the safe option.
## 13. Open questions
1. Cadence of the core train once the drift SLO lands: stay biweekly or move
to weekly? At current velocity the under-100 batch target in section 9 is
reachable only with weekly trains, so this question blocks that one
metric's horizon. (Rotation owner plus core release stakeholders; blocking
for the batch-size target, non-blocking for everything else.)
2. Should the prod pointer cover core GA and desktop as refs too
(`deployed/core-ga`), or is PyPI plus the pin authoritative enough?
(Frontend leads; non-blocking.)
3. Who owns the standing QA gate criteria documents long-term as QA staffing
changes? (QA plus FE eng manager; blocking for Phase 0 sign-off.)
4. Does the org-admin limitation on separate go-live approvers (surfaced
during FE-1176) need resolution before GitHub Environments become the prod
gate? (DevOps plus org admins; blocking for P1 item 8.)
## 14. References
Internal: `docs/release-process.md` (the operational runbook of record,
updated as phases land); the shipping-speed initiative and its release-gate
and canary design docs; the release rotation runbook; the
premature-version-bump post-mortem; FE-713, FE-1176, FE-1282, FE-602/BE-800;
the draft 4-tier proposal this document reviews.
External: see section 5 source list.

View File

@@ -4,6 +4,7 @@ import { config as dotenvConfig } from 'dotenv'
import MCR from 'monocart-coverage-reports'
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
import { TOURS, TOUR_SEEN_SETTING } from '@/platform/onboarding/onboardingTours'
import { NodeBadgeMode } from '@/types/nodeSource'
import { ComfyActionbar } from '@e2e/fixtures/components/Actionbar'
import { ComfyTemplates } from '@e2e/fixtures/components/Templates'
@@ -542,6 +543,8 @@ export const comfyPageFixture = base.extend<{
'Comfy.userId': userId,
// Set tutorial completed to true to avoid loading the tutorial workflow.
'Comfy.TutorialCompleted': true,
// An auto-opened tour's blocker would break unrelated tests.
[TOUR_SEEN_SETTING]: Object.keys(TOURS),
'Comfy.Queue.MaxHistoryItems': 64,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
// Disable toast warning about version compatibility, as they may or

View File

@@ -8,11 +8,13 @@ export class ComfyActionbar {
public readonly root: Locator
public readonly queueButton: ComfyQueueButton
public readonly propertiesButton: Locator
public readonly dragHandle: Locator
constructor(public readonly page: Page) {
this.root = page.locator('.actionbar-container')
this.queueButton = new ComfyQueueButton(this)
this.propertiesButton = this.root.getByLabel('Toggle properties panel')
this.dragHandle = this.root.locator('.drag-handle')
}
async isDocked() {

View File

@@ -0,0 +1,21 @@
import type { Locator } from '@playwright/test'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
export class FreeTierQuota {
readonly root: Locator
constructor(comfyPage: ComfyPage) {
this.root = comfyPage.page.getByTestId(TestIds.topbar.freeTierQuota)
}
async getMax() {
const text = await this.root.textContent()
return text?.match(/(\d+) \/ (\d+)/)?.[2]
}
async getAvailable() {
const text = await this.root.textContent()
return text?.match(/(\d+) \/ (\d+)/)?.[1]
}
}

View File

@@ -0,0 +1,77 @@
import type { Locator, Page } from '@playwright/test'
import { TOUR_SEEN_SETTING } from '@/platform/onboarding/onboardingTours'
export type CoachTour = 'appMode'
/** Accessible name of each tour's in-app replay (help) button. */
const TOUR_REPLAY_BUTTONS: Record<CoachTour, string> = {
appMode: 'Take a tour of App Mode'
}
/** Coach-mark overlay (src/platform/onboarding/TourOverlay.vue). */
export class OnboardingCoachmarks {
public readonly landing: Locator
public readonly landingStartButton: Locator
public readonly landingSkipButton: Locator
/** The current spotlight step card (the dialog carrying a "Step N of M" label). */
public readonly card: Locator
public readonly cardNextButton: Locator
public readonly cardDoneButton: Locator
constructor(public readonly page: Page) {
this.landing = page.getByTestId('coach-landing')
this.landingStartButton = this.landing.getByRole('button', {
name: 'Start tutorial'
})
this.landingSkipButton = this.landing.getByRole('button', {
name: 'Skip',
exact: true
})
this.card = page.getByRole('dialog').filter({ hasText: /Step \d+ of \d+/ })
this.cardNextButton = this.card.getByRole('button', { name: 'Next' })
this.cardDoneButton = this.card.getByRole('button', { name: 'Done' })
}
/** The tour's in-app help button, which replays it past the seen-flag. */
replayButton(tour: CoachTour): Locator {
return this.page.getByRole('button', { name: TOUR_REPLAY_BUTTONS[tour] })
}
/** The spotlight card while it is showing the given step number. */
cardForStep(step: number): Locator {
return this.card.filter({ hasText: new RegExp(`Step ${step} of `) })
}
/**
* Clears the pre-seeded seen-flag (so dismissal assertions observe it being
* set again) and clicks the tour's replay button, which must be mounted.
*/
async startTour(tour: CoachTour) {
await this.clearSeen()
await this.replayButton(tour).click()
}
private async clearSeen() {
await this.page.evaluate(
async (key) => window.app!.extensionManager.setting.set(key, []),
TOUR_SEEN_SETTING
)
}
/** An element a tour points at, by its `data-coach-id` anchor. */
coachAnchor(id: string): Locator {
return this.page.locator(`[data-coach-id="${id}"]`)
}
async seen(tour: CoachTour): Promise<boolean> {
const seen = await this.page.evaluate(
async (key) =>
(await window.app!.extensionManager.setting.get(key)) as
| string[]
| undefined,
TOUR_SEEN_SETTING
)
return !!seen?.includes(tour)
}
}

View File

@@ -103,7 +103,8 @@ export const TestIds = {
loginButtonPopoverLearnMore: 'login-button-popover-learn-more',
workflowTabs: 'topbar-workflow-tabs',
integratedTabBarActions: 'integrated-tab-bar-actions',
actionBarButtons: 'action-bar-buttons'
actionBarButtons: 'action-bar-buttons',
freeTierQuota: 'free-tier-quota'
},
nodeLibrary: {
bookmarksSection: 'node-library-bookmarks-section'

View File

@@ -0,0 +1,11 @@
import { test as base } from '@playwright/test'
import { OnboardingCoachmarks } from '@e2e/fixtures/components/Tour'
export const onboardingFixture = base.extend<{
onboarding: OnboardingCoachmarks
}>({
onboarding: async ({ page }, use) => {
await use(new OnboardingCoachmarks(page))
}
})

View File

@@ -28,11 +28,11 @@ const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
// matches it against the members self-row.
const SELF_EMAIL = 'e2e@test.comfy.org'
// consolidated_billing_enabled routes personal workspaces to the unified
// pricing table asserted here; without it they fall back to the legacy table.
// billing_control_enabled routes personal workspaces to the unified pricing
// table asserted here; without it they fall back to the legacy table.
const BOOT_FEATURES = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
billing_control_enabled: true
} satisfies RemoteConfig
// Disable the experimental Asset API: with it on (cloud default) the unmocked
// asset endpoints 403 and workflow restore throws uncaught, aborting the

View File

@@ -0,0 +1,63 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
import { FreeTierQuota } from '@e2e/fixtures/components/FreeTierQuota'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { webSocketFixture } from '@e2e/fixtures/ws'
const wstest = mergeTests(test, webSocketFixture)
test.describe('Free Tier Quota', { tag: ['@cloud', '@vue-nodes'] }, () => {
test.beforeEach(async ({ page }) => {
const features = {
free_tier_job_allowance_enabled: true,
free_tier_balance: { allowance: 5, remaining: 3, used: 0 }
}
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
})
wstest('Free Tier Quota', async ({ comfyPage, comfyMouse, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
const freeTierQuota = new FreeTierQuota(comfyPage)
await test.step('Populates initial state from config', async () => {
await expect.poll(() => freeTierQuota.getAvailable()).toBe('3')
expect(await freeTierQuota.getMax()).toBe('5')
})
await test.step('available decrements on run', async () => {
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
})
await test.step('connects to detached run button', async () => {
const handle = comfyPage.actionbar.dragHandle
await comfyMouse.dragElementBy(handle, { x: -100, y: 100 })
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(false)
expect(await freeTierQuota.getAvailable()).toBe('2')
await comfyMouse.dragElementBy(handle, { x: 100, y: -100 })
await expect.poll(() => comfyPage.actionbar.isDocked()).toBe(true)
})
await test.step('Detects workflows with Partner nodes', async () => {
await comfyPage.searchBoxV2.addNode('Node With Price Badge')
const node = await comfyPage.vueNodes.getFixtureByTitle('Price Badge')
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
await node.delete()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('2')
})
await test.step('Does not decrease past 0', async () => {
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe('1')
await execution.run()
await expect.poll(() => freeTierQuota.getAvailable()).toBe(undefined)
await execution.run()
await execution.run()
await execution.run()
await comfyPage.nextFrame()
expect(await freeTierQuota.getAvailable()).toBe(undefined)
})
})
})

View File

@@ -0,0 +1,135 @@
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { onboardingFixture } from '@e2e/fixtures/tourFixture'
import {
COACH_IDS,
TOUR_SEEN_SETTING
} from '@/platform/onboarding/onboardingTours'
const test = mergeTests(comfyPageFixture, onboardingFixture)
// Relies on the test server's default workflow (locally: pnpm dev:test).
test.describe('Onboarding coachmarks', { tag: '@ui' }, () => {
test.describe('app-mode tour', () => {
// With no tour pre-seeded as seen, entering the app auto-opens it.
test.use({
initialSettings: { [TOUR_SEEN_SETTING]: [] }
})
test('auto-opens on the welcome landing, focuses Start, and Skip dismisses it', async ({
comfyPage,
onboarding
}) => {
await comfyPage.appMode.enterAppModeWithInputs([])
const coach = onboarding
await expect(coach.landing).toBeVisible()
await expect(coach.landing.getByRole('heading')).toHaveText(
'Welcome to Apps'
)
await expect(coach.landingStartButton).toBeFocused()
await coach.landingSkipButton.click()
await expect(coach.landing).toBeHidden()
await expect.poll(() => coach.seen('appMode')).toBe(true)
})
test('Escape dismisses the welcome landing and marks it seen', async ({
comfyPage,
onboarding
}) => {
await comfyPage.appMode.enterAppModeWithInputs([])
const coach = onboarding
await expect(coach.landing).toBeVisible()
await expect(coach.landingStartButton).toBeFocused()
await comfyPage.page.keyboard.press('Escape')
await expect(coach.landing).toBeHidden()
await expect.poll(() => coach.seen('appMode')).toBe(true)
})
})
test.describe('coach anchors', () => {
test('every registry id resolves to an element (drift guard)', async ({
comfyPage,
onboarding
}) => {
const coach = onboarding
await comfyPage.appMode.enterAppModeWithInputs([])
// The assets panel only mounts once the assets sidebar tab is open.
for (const id of Object.values(COACH_IDS).filter(
(id) => id !== COACH_IDS.assetsPanel
)) {
await expect(coach.coachAnchor(id)).toBeVisible()
}
await comfyPage.menu.assetsTab.tabButton.click()
await expect(coach.coachAnchor(COACH_IDS.assetsPanel)).toBeVisible()
})
})
test.describe('spotlight focus', () => {
test('focuses the primary action, traps Tab in the card, and re-focuses per step', async ({
comfyPage,
onboarding
}) => {
const coach = onboarding
await comfyPage.page.emulateMedia({ reducedMotion: 'reduce' })
await comfyPage.appMode.enterAppModeWithInputs([])
await coach.startTour('appMode')
await expect(coach.landing).toBeVisible()
await coach.landingStartButton.click()
const step1 = coach.cardForStep(1)
await expect(step1).toBeVisible()
// FocusScope's mount-auto-focus is suppressed so focus lands on the
// primary action rather than the first focusable (Skip).
await expect(coach.cardNextButton).toBeFocused()
// Tab more times than the card has controls; the looped trap must keep
// focus inside the card, never escaping to the app behind the overlay.
for (let i = 0; i < 4; i++) {
await comfyPage.page.keyboard.press('Tab')
await expect(step1.locator(':focus')).toBeVisible()
}
await comfyPage.page.keyboard.press('Shift+Tab')
await expect(step1.locator(':focus')).toBeVisible()
await coach.cardNextButton.click()
await expect(coach.cardForStep(2)).toBeVisible()
await expect(coach.cardNextButton).toBeFocused()
})
})
test.describe('spotlight placement', () => {
test('every spotlight card stays fully within the viewport and Done completes the tour', async ({
comfyPage,
onboarding
}) => {
const coach = onboarding
// Read settled placements, not a transient mid-animation frame.
await comfyPage.page.emulateMedia({ reducedMotion: 'reduce' })
await comfyPage.appMode.enterAppModeWithInputs([])
await coach.startTour('appMode')
await expect(coach.landing).toBeVisible()
await coach.landingStartButton.click()
for (const step of [1, 2, 3]) {
const card = coach.cardForStep(step)
await expect(card).toBeVisible()
await expect(card).toBeInViewport({ ratio: 1 })
await coach.cardNextButton.click()
}
// The final assets step auto-opens the assets panel — no target click.
await expect(coach.cardForStep(4)).toBeInViewport({ ratio: 1 })
await coach.cardDoneButton.click()
await expect(coach.card).toBeHidden()
await expect.poll(() => coach.seen('appMode')).toBe(true)
})
})
})

View File

@@ -29,6 +29,8 @@
"dev:test": "cross-env VITE_USE_LEGACY_DEFAULT_GRAPH=true vite --config vite.config.mts",
"dev": "vite --config vite.config.mts",
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
"fallow": "fallow",
"fallow:audit": "fallow audit",
"format:check": "oxfmt --check",
"format": "oxfmt --write",
"json-schema": "tsx scripts/generate-json-schema.ts",
@@ -72,6 +74,7 @@
"@comfyorg/shared-frontend-utils": "workspace:*",
"@comfyorg/tailwind-utils": "workspace:*",
"@customerio/cdp-analytics-browser": "catalog:",
"@floating-ui/vue": "catalog:",
"@formkit/auto-animate": "catalog:",
"@iconify/json": "catalog:",
"@primeuix/forms": "catalog:",
@@ -172,6 +175,7 @@
"eslint-plugin-testing-library": "catalog:",
"eslint-plugin-unused-imports": "catalog:",
"eslint-plugin-vue": "catalog:",
"fallow": "catalog:",
"fast-check": "catalog:",
"fs-extra": "^11.2.0",
"globals": "catalog:",

View File

@@ -102,6 +102,10 @@
--color-alpha-magenta-700-60: #6a246a99;
--color-alpha-magenta-300-60: #ceaac999;
/* Onboarding coachmark overlay (always renders over a dark scrim) */
--color-coach-scrim: rgb(0 0 0 / 0.6);
--color-coach-ring: #fff;
/* PrimeVue pulled colors */
--color-muted: var(--p-text-muted-color);
--color-highlight: var(--p-primary-color);

94
pnpm-lock.yaml generated
View File

@@ -30,6 +30,9 @@ catalogs:
'@eslint/js':
specifier: ^10.0.1
version: 10.0.1
'@floating-ui/vue':
specifier: ^1.1.11
version: 1.1.11
'@formkit/auto-animate':
specifier: ^0.9.0
version: 0.9.0
@@ -240,6 +243,9 @@ catalogs:
eslint-plugin-vue:
specifier: ^10.9.1
version: 10.9.1
fallow:
specifier: ^2.102.0
version: 2.102.0
fast-check:
specifier: ^4.5.3
version: 4.5.3
@@ -468,6 +474,9 @@ importers:
'@customerio/cdp-analytics-browser':
specifier: 'catalog:'
version: 0.5.3
'@floating-ui/vue':
specifier: 'catalog:'
version: 1.1.11(vue@3.5.34(typescript@5.9.3))
'@formkit/auto-animate':
specifier: 'catalog:'
version: 0.9.0
@@ -763,6 +772,9 @@ importers:
eslint-plugin-vue:
specifier: 'catalog:'
version: 10.9.1(@typescript-eslint/parser@8.60.0(eslint@10.4.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.4.0(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.4.0(jiti@2.6.1)))
fallow:
specifier: 'catalog:'
version: 2.102.0
fast-check:
specifier: 'catalog:'
version: 4.5.3
@@ -1908,6 +1920,46 @@ packages:
'@exodus/crypto':
optional: true
'@fallow-cli/darwin-arm64@2.102.0':
resolution: {integrity: sha512-B8wzfzJgoX6h5Gv2xQ9ZidO5Jb8/PWdssAxYbWs1pb5oJHZ6S5PLwXuUdINmNSIaRGQwTk4DC9/tIMFHFvd9uw==}
cpu: [arm64]
os: [darwin]
'@fallow-cli/darwin-x64@2.102.0':
resolution: {integrity: sha512-aLbTWWzQnleKdi56obAPXMJm7YA3qAnkIX9T3eocRHiagYqp8nsf4cslM0rZKvu2WwK34NaBm8x886gl4cl+zg==}
cpu: [x64]
os: [darwin]
'@fallow-cli/linux-arm64-gnu@2.102.0':
resolution: {integrity: sha512-8nYeOSLSewqcKH/KUcKZaCq5QII5VTRX62l60B6UM1iKe/0jcmlQ2mOtx4rkHpeq3LL5emvT/ph4NNgGmWKSBg==}
cpu: [arm64]
os: [linux]
'@fallow-cli/linux-arm64-musl@2.102.0':
resolution: {integrity: sha512-2Zi33PXzZxD7HU5sPVwyElZGP7zyEdGo4hK0ewy6gMYgQ9BDfLnFhgaSSOzN1J4paIhYtBmVsLmqakyDKy22Jw==}
cpu: [arm64]
os: [linux]
'@fallow-cli/linux-x64-gnu@2.102.0':
resolution: {integrity: sha512-7Hys4X6hKuR/lqUaGXwezRzDrwXwu9KfahUy85WTuiG1to+ZbzDCqdbZ04LtnI8kK8ufrPDcq+ZXdyt5ksOJHA==}
cpu: [x64]
os: [linux]
'@fallow-cli/linux-x64-musl@2.102.0':
resolution: {integrity: sha512-RuDY1jOEPgJOuHBgEpHVl6J7Xf2QLFklnNy6zO1nw8R1fLgWUAAKlFirn1Y93pb9bXzqpn5Gre3YlhgIZ3+LBA==}
cpu: [x64]
os: [linux]
'@fallow-cli/win32-arm64-msvc@2.102.0':
resolution: {integrity: sha512-wsvHjLzWFvsYmCqnQLm1doSEHb9Z038+sFp1RzMcffPUBC5tqS3vCr8J8nolcLlPk7o3QWHQMXAqb1xSJe+doA==}
cpu: [arm64]
os: [win32]
'@fallow-cli/win32-x64-msvc@2.102.0':
resolution: {integrity: sha512-rH1hd0PD0mm6pCxh1pw5jubpJsvV6f5rjixMoD5AZLzWa6NPJBpPPuzruLGtH/9CYy8B0y7zPrGGTKRO2PCGzg==}
cpu: [x64]
os: [win32]
'@firebase/analytics-compat@0.2.18':
resolution: {integrity: sha512-Hw9mzsSMZaQu6wrTbi3kYYwGw9nBqOHr47pVLxfr5v8CalsdrG5gfs9XUlPOZjHRVISp3oQrh1j7d3E+ulHPjQ==}
peerDependencies:
@@ -5682,6 +5734,11 @@ packages:
extendable-media-recorder@9.2.27:
resolution: {integrity: sha512-2X+Ixi1cxLek0Cj9x9atmhQ+apG+LwJpP2p3ypP8Pxau0poDnicrg7FTfPVQV5PW/3DHFm/eQ16vbgo5Yk3HGQ==}
fallow@2.102.0:
resolution: {integrity: sha512-bkOT58kPVCB12d2apQjIKBw/qSdsGRPQFrN5ff9Yl5WzXRqlDTbT/MVdMXld4sJD5JQW1ftw2bTxJWCINggh6g==}
engines: {node: '>=16'}
hasBin: true
fast-check@4.5.3:
resolution: {integrity: sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==}
engines: {node: '>=12.17.0'}
@@ -10044,6 +10101,30 @@ snapshots:
'@exodus/bytes@1.7.0': {}
'@fallow-cli/darwin-arm64@2.102.0':
optional: true
'@fallow-cli/darwin-x64@2.102.0':
optional: true
'@fallow-cli/linux-arm64-gnu@2.102.0':
optional: true
'@fallow-cli/linux-arm64-musl@2.102.0':
optional: true
'@fallow-cli/linux-x64-gnu@2.102.0':
optional: true
'@fallow-cli/linux-x64-musl@2.102.0':
optional: true
'@fallow-cli/win32-arm64-msvc@2.102.0':
optional: true
'@fallow-cli/win32-x64-msvc@2.102.0':
optional: true
'@firebase/analytics-compat@0.2.18(@firebase/app-compat@0.2.53)(@firebase/app@0.11.4)':
dependencies:
'@firebase/analytics': 0.10.12(@firebase/app@0.11.4)
@@ -14095,6 +14176,19 @@ snapshots:
subscribable-things: 2.1.53
tslib: 2.8.1
fallow@2.102.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
'@fallow-cli/darwin-arm64': 2.102.0
'@fallow-cli/darwin-x64': 2.102.0
'@fallow-cli/linux-arm64-gnu': 2.102.0
'@fallow-cli/linux-arm64-musl': 2.102.0
'@fallow-cli/linux-x64-gnu': 2.102.0
'@fallow-cli/linux-x64-musl': 2.102.0
'@fallow-cli/win32-arm64-msvc': 2.102.0
'@fallow-cli/win32-x64-msvc': 2.102.0
fast-check@4.5.3:
dependencies:
pure-rand: 7.0.1

View File

@@ -18,6 +18,7 @@ catalog:
'@comfyorg/comfyui-electron-types': 0.6.2
'@customerio/cdp-analytics-browser': ^0.5.3
'@eslint/js': ^10.0.1
'@floating-ui/vue': ^1.1.11
'@formkit/auto-animate': ^0.9.0
'@iconify-json/lucide': ^1.1.178
'@iconify/json': ^2.2.380
@@ -89,6 +90,7 @@ catalog:
eslint-plugin-testing-library: ^7.16.1
eslint-plugin-unused-imports: ^4.4.1
eslint-plugin-vue: ^10.9.1
fallow: ^2.102.0
fast-check: ^4.5.3
firebase: ^11.6.0
glob: ^13.0.6

Binary file not shown.

After

Width:  |  Height:  |  Size: 281 KiB

View File

@@ -15,6 +15,7 @@ const IGNORE_PATTERNS = [
/^dataTypes\./, // Data types might be referenced dynamically
/^contextMenu\./, // Context menu items might be dynamic
/^color\./, // Color names might be used dynamically
/^onboardingCoachmarks\.[^.]+\.[^.]+\./, // Step keys derived as onboardingCoachmarks.<tour>.<step>.*
// Auto-generated categories from collect-i18n-general.ts
/^menuLabels\./, // Menu labels generated from command labels
/^settingsCategories\./, // Settings categories generated from setting definitions

View File

@@ -629,7 +629,7 @@ describe('TopMenuSection', () => {
await nextTick()
expect(querySpy).toHaveBeenCalledTimes(1)
expect(actionbarContainer!.classList).toContain('px-2')
expect(actionbarContainer!.classList).not.toContain('w-0')
} finally {
unmount()
vi.unstubAllGlobals()

View File

@@ -11,7 +11,7 @@
</div>
<div class="mx-1 flex flex-col items-end gap-1">
<div class="flex items-center gap-2">
<div class="flex items-start gap-2">
<div
v-if="managerState.shouldShowManagerButtons.value || isCloud"
class="pointer-events-auto flex h-12 shrink-0 items-center rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 shadow-interface"
@@ -34,61 +34,75 @@
</Button>
</div>
<div ref="actionbarContainerRef" :class="actionbarContainerClass">
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
class="pointer-events-auto z-1 flex flex-col rounded-lg border border-interface-stroke bg-comfy-menu-bg px-2 py-1.75 shadow-interface"
>
<div
ref="legacyCommandsContainerRef"
data-testid="legacy-topbar-container"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
ref="actionbarContainerRef"
:class="
cn(
'actionbar-container relative flex items-center gap-2',
isActionbarContainerEmpty &&
'-ml-2 w-0 min-w-0 border-transparent shadow-none has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
"
>
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
<div v-if="!isRightSidePanelOpen" class="relative">
<Button
v-tooltip.bottom="rightSidePanelTooltipConfig"
:class="
cn(
showErrorIndicatorOnPanelButton &&
'outline-1 outline-destructive-background'
)
"
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="openRightSidePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<StatusBadge
v-if="showErrorIndicatorOnPanelButton"
variant="dot"
severity="danger"
class="absolute -top-1 -right-1"
<ActionBarButtons />
<!-- Support for legacy topbar elements attached by custom scripts, hidden if no elements present -->
<div
ref="legacyCommandsContainerRef"
data-testid="legacy-topbar-container"
class="[&:not(:has(*>*:not(:empty)))]:hidden"
></div>
<ComfyActionbar
:top-menu-container="actionbarContainerRef"
:queue-overlay-expanded="isQueueOverlayExpanded"
@update:progress-target="updateProgressTarget"
/>
<CurrentUserButton
v-if="isLoggedIn && !isIntegratedTabBar"
class="shrink-0"
/>
<LoginButton v-else-if="isDesktop && !isIntegratedTabBar" />
<Button
v-if="isCloud && flags.workflowSharingEnabled"
v-tooltip.bottom="shareTooltipConfig"
variant="secondary"
:aria-label="t('actionbar.shareTooltip')"
@click="() => openShareDialog().catch(toastErrorHandler)"
@pointerenter="prefetchShareDialog"
>
<i class="icon-[comfy--send] size-4" />
<span class="not-md:hidden">
{{ t('actionbar.share') }}
</span>
</Button>
<div v-if="!isRightSidePanelOpen" class="relative">
<Button
v-tooltip.bottom="rightSidePanelTooltipConfig"
:class="
cn(
showErrorIndicatorOnPanelButton &&
'outline-1 outline-destructive-background'
)
"
variant="secondary"
size="icon"
:aria-label="t('rightSidePanel.togglePanel')"
@click="openRightSidePanel"
>
<i class="icon-[lucide--panel-right] size-4" />
</Button>
<StatusBadge
v-if="showErrorIndicatorOnPanelButton"
variant="dot"
severity="danger"
class="absolute -top-1 -right-1"
/>
</div>
</div>
<FreeTierQuota v-if="!isActionbarFloating" />
</div>
</div>
<ErrorOverlay />
@@ -147,6 +161,7 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
@@ -209,21 +224,6 @@ const hasDockedButtons = computed(() => {
const isActionbarContainerEmpty = computed(
() => isActionbarFloating.value && !hasDockedButtons.value
)
const actionbarContainerClass = computed(() => {
const base =
'actionbar-container pointer-events-auto relative flex h-12 items-center gap-2 rounded-lg border bg-comfy-menu-bg shadow-interface'
if (isActionbarContainerEmpty.value) {
return cn(
base,
'-ml-2 w-0 min-w-0 border-transparent shadow-none',
'has-[.border-dashed]:ml-0 has-[.border-dashed]:w-auto has-[.border-dashed]:min-w-auto',
'has-[.border-dashed]:border-interface-stroke has-[.border-dashed]:pl-2 has-[.border-dashed]:shadow-interface'
)
}
return cn(base, 'px-2', 'border-interface-stroke')
})
const isIntegratedTabBar = computed(
() => settingStore.get('Comfy.UI.TabBarLayout') !== 'Legacy'
)

View File

@@ -11,6 +11,7 @@
<Panel
ref="panelRef"
data-testid="comfy-actionbar"
class="pointer-events-auto"
:style="style"
:class="panelClass"
@@ -75,6 +76,7 @@
</Button>
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
</div>
<FreeTierQuota v-if="!isDocked" />
</Panel>
<Teleport v-if="inlineProgressTarget" :to="inlineProgressTarget">
@@ -109,6 +111,7 @@ import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import FreeTierQuota from '@/platform/cloud/subscription/components/FreeTierQuota.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCommandStore } from '@/stores/commandStore'

View File

@@ -4,11 +4,11 @@ import { nextTick, ref } from 'vue'
import CloudRunButtonWrapper from './CloudRunButtonWrapper.vue'
const mockIsActiveSubscription = ref(true)
const mockCanRunWorkflows = ref(true)
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: mockIsActiveSubscription
canRunWorkflows: mockCanRunWorkflows
})
}))
@@ -32,7 +32,7 @@ function renderWrapper() {
describe('CloudRunButtonWrapper', () => {
beforeEach(() => {
mockIsActiveSubscription.value = true
mockCanRunWorkflows.value = true
})
it('renders the runnable queue button when the subscription is active', () => {
@@ -45,7 +45,7 @@ describe('CloudRunButtonWrapper', () => {
})
it('locks the run button when the subscription is inactive', () => {
mockIsActiveSubscription.value = false
mockCanRunWorkflows.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
@@ -53,12 +53,12 @@ describe('CloudRunButtonWrapper', () => {
})
it('unlocks the run button once the subscription becomes active again', async () => {
mockIsActiveSubscription.value = false
mockCanRunWorkflows.value = false
renderWrapper()
expect(screen.getByTestId('subscribe-to-run-button')).toBeInTheDocument()
mockIsActiveSubscription.value = true
mockCanRunWorkflows.value = true
await nextTick()
expect(screen.getByTestId('queue-button')).toBeInTheDocument()

View File

@@ -1,7 +1,7 @@
<template>
<component
:is="currentButton"
:key="isActiveSubscription ? 'queue' : 'subscribe'"
:key="canRunWorkflows ? 'queue' : 'subscribe'"
/>
</template>
<script setup lang="ts">
@@ -11,9 +11,9 @@ import ComfyQueueButton from '@/components/actionbar/ComfyRunButton/ComfyQueueBu
import { useBillingContext } from '@/composables/billing/useBillingContext'
import SubscribeToRunButton from '@/platform/cloud/subscription/components/SubscribeToRun.vue'
const { isActiveSubscription } = useBillingContext()
const { canRunWorkflows } = useBillingContext()
const currentButton = computed(() =>
isActiveSubscription.value ? ComfyQueueButton : SubscribeToRunButton
canRunWorkflows.value ? ComfyQueueButton : SubscribeToRunButton
)
</script>

View File

@@ -1,6 +1,10 @@
import { ZIndex } from '@primeuix/utils/zindex'
import type { Directive } from 'vue'
/** Shared PrimeVue/Reka modal stacking sequence; later registrations cover earlier ones. */
export const MODAL_Z_KEY = 'modal'
export const MODAL_Z_BASE = 1700
// Both Reka and PrimeVue dialogs can appear at any depth in dialogStack, in
// any order. PrimeVue auto-increments a per-key z-index counter so later
// dialogs always cover earlier ones; Reka uses a static z-1700 class which
@@ -9,7 +13,7 @@ import type { Directive } from 'vue'
// renderers share one stacking sequence: whichever dialog opens last wins.
export const vRekaZIndex: Directive<HTMLElement> = {
mounted(el) {
ZIndex.set('modal', el, 1700)
ZIndex.set(MODAL_Z_KEY, el, MODAL_Z_BASE)
},
beforeUnmount(el) {
ZIndex.clear(el)

View File

@@ -180,6 +180,7 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import { useFirstRunTourController } from '@/renderer/extensions/firstRunTour/useFirstRunTourController'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { UnauthorizedError } from '@/scripts/api'
@@ -504,6 +505,11 @@ useEventListener(
onMounted(async () => {
comfyApp.vueAppReady = true
workspaceStore.spinner = true
let templateFromUrl:
| Awaited<
ReturnType<typeof workflowPersistence.loadTemplateFromUrlIfPresent>
>
| undefined
try {
// ChangeTracker needs to be initialized before setup, as it will overwrite
// some listeners of litegraph canvas.
@@ -561,11 +567,38 @@ onMounted(async () => {
// Restore saved workflow and workflow tabs state
await workflowPersistence.initializeWorkflow()
await workflowPersistence.restoreWorkflowTabsState()
await workflowPersistence.loadTemplateFromUrlIfPresent()
templateFromUrl = await workflowPersistence.loadTemplateFromUrlIfPresent()
} finally {
workspaceStore.spinner = false
}
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
const sharedFromUrl =
await workflowPersistence.loadSharedWorkflowFromUrlIfPresent()
// A ?template=/?share= URL loads a workflow directly (Getting Started is
// skipped). Start the onboarding tour on it; beginTour() self-gates and reads
// the now-loaded graph, so the loaders above must have finished first.
const startTourFromUrl = (
options?: Parameters<
ReturnType<typeof useFirstRunTourController>['beginTour']
>[0]
) =>
useFirstRunTourController()
.beginTour(options)
.catch((error: unknown) => {
console.error('[onboardingTour] failed to start from URL', error)
})
if (templateFromUrl?.loaded) {
void startTourFromUrl({
templateId: templateFromUrl.templateId,
entry: 'template_url'
})
} else if (
sharedFromUrl === 'loaded' ||
sharedFromUrl === 'loaded-without-assets'
) {
void startTourFromUrl({ entry: 'share_url' })
}
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,

View File

@@ -1,14 +1,21 @@
<template>
<div role="tablist" class="flex w-full items-center gap-2">
<div role="tablist" :class="cn('flex w-full items-center gap-2', className)">
<slot />
</div>
</template>
<script setup lang="ts" generic="T extends string = string">
import type { HTMLAttributes } from 'vue'
import { provide } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { TAB_LIST_INJECTION_KEY } from './tabKeys'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
const modelValue = defineModel<T>({ required: true })
function select(value: string) {

View File

@@ -112,5 +112,13 @@ export interface BillingContext extends BillingState, BillingActions {
* (legacy) per-member tier plan, which keeps the old team pricing table.
*/
isLegacyTeamPlan: ComputedRef<boolean>
/**
* True when the subscription is a team plan of either generation. Unlike
* `isLegacyTeamPlan` this does not require an active subscription: the spend
* gate folds billing_status into is_active, so a paused or payment-failed team
* plan reports is_active=false and must still read as a team plan.
*/
isTeamPlan: ComputedRef<boolean>
getMaxSeats: (tierKey: TierKey) => number
canRunWorkflows: ComputedRef<boolean>
}

View File

@@ -19,7 +19,7 @@ const DEFAULT_BILLING_STATUS: BillingStatusResponse = {
const {
mockTeamWorkspacesEnabled,
mockConsolidatedBillingEnabled,
mockBillingControlEnabled,
mockIsPersonal,
mockPlans,
mockPurchaseCredits,
@@ -27,7 +27,7 @@ const {
mockBillingStatus
} = vi.hoisted(() => ({
mockTeamWorkspacesEnabled: { value: false },
mockConsolidatedBillingEnabled: { value: false },
mockBillingControlEnabled: { value: false },
mockIsPersonal: { value: true },
mockPlans: { value: [] as Plan[] },
mockPurchaseCredits: vi.fn(),
@@ -59,13 +59,11 @@ vi.mock('@/composables/useFeatureFlags', async () => {
teamWorkspacesEnabledRef.value = value
}
})
const consolidatedBillingEnabledRef = ref(
mockConsolidatedBillingEnabled.value
)
Object.defineProperty(mockConsolidatedBillingEnabled, 'value', {
get: () => consolidatedBillingEnabledRef.value,
const billingControlEnabledRef = ref(mockBillingControlEnabled.value)
Object.defineProperty(mockBillingControlEnabled, 'value', {
get: () => billingControlEnabledRef.value,
set: (value: boolean) => {
consolidatedBillingEnabledRef.value = value
billingControlEnabledRef.value = value
}
})
return {
@@ -74,8 +72,8 @@ vi.mock('@/composables/useFeatureFlags', async () => {
get teamWorkspacesEnabled() {
return mockTeamWorkspacesEnabled.value
},
get consolidatedBillingEnabled() {
return mockConsolidatedBillingEnabled.value
get billingControlEnabled() {
return mockBillingControlEnabled.value
}
}
})
@@ -165,7 +163,7 @@ describe('useBillingContext', () => {
setActivePinia(createPinia())
vi.clearAllMocks()
mockTeamWorkspacesEnabled.value = false
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = true
mockPlans.value = []
mockBillingStatus.value = { ...DEFAULT_BILLING_STATUS }
@@ -177,27 +175,27 @@ describe('useBillingContext', () => {
expect(type.value).toBe('legacy')
})
it('keeps personal on legacy when consolidated billing is disabled', () => {
it('keeps personal on legacy when billing control is disabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('legacy')
})
it('selects workspace type for personal when consolidated billing is enabled', () => {
it('selects workspace type for personal when billing control is enabled', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
mockIsPersonal.value = true
const { type } = useBillingContext()
expect(type.value).toBe('workspace')
})
it('selects workspace type for team regardless of consolidated billing', () => {
it('selects workspace type for team regardless of billing control', () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = false
const { type } = useBillingContext()
@@ -298,7 +296,7 @@ describe('useBillingContext', () => {
expect(workspaceApi.getBillingStatus).not.toHaveBeenCalled()
// Authenticated remote config resolves the flag on for the same workspace
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
mockTeamWorkspacesEnabled.value = true
await vi.waitFor(() => {
@@ -307,16 +305,16 @@ describe('useBillingContext', () => {
})
})
it('moves a personal workspace to workspace billing when consolidated billing flips on', async () => {
it('moves a personal workspace to workspace billing when billing control flips on', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = false
mockBillingControlEnabled.value = false
mockIsPersonal.value = true
const { type } = useBillingContext()
await nextTick()
expect(type.value).toBe('legacy')
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
await vi.waitFor(() => {
expect(type.value).toBe('workspace')
@@ -325,9 +323,9 @@ describe('useBillingContext', () => {
})
describe('subscription mirror to workspace store', () => {
it('mirrors subscription for personal workspaces on the consolidated billing flow', async () => {
it('mirrors subscription for personal workspaces on the billing control flow', async () => {
mockTeamWorkspacesEnabled.value = true
mockConsolidatedBillingEnabled.value = true
mockBillingControlEnabled.value = true
mockIsPersonal.value = true
const { initialize } = useBillingContext()
@@ -555,4 +553,110 @@ describe('useBillingContext', () => {
expect(isLegacyTeamPlan.value).toBe(true)
})
})
describe('isTeamPlan', () => {
it('is false for a personal workspace', () => {
const { isTeamPlan } = useBillingContext()
expect(isTeamPlan.value).toBe(false)
})
// subscription_tier is omitted throughout: the backend sends 'TEAM' here, but
// the FE's SubscriptionTier resolves to the registry spec, which has no TEAM
// (tierPricing.ts imports comfyRegistryTypes for what is an ingest field).
// isTeamPlan reads the credit stop and the slug, never the tier — which is
// what keeps it working despite that divergence.
it('is true for a credit-slider team sub, which carries a credit stop', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
plan_slug: 'team_per_credit_monthly',
team_credit_stop: {
id: 'team_700',
credits_monthly: 700,
stop_usd: 332
}
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
it('is true for a legacy team sub, identified by slug rather than credit stop', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'STANDARD',
plan_slug: 'team-standard-annual'
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
// The banner states that need isTeamPlan most — paused and payment_failed —
// are exactly the ones the backend reports with is_active=false, because the
// spend gate folds billing_status into it. Coupling isTeamPlan to an active
// subscription would blank the banner precisely when it is needed.
it('stays true for a paused team plan, which the backend reports inactive', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: false,
has_funds: true,
billing_status: 'paused',
plan_slug: 'team_per_credit_monthly',
team_credit_stop: {
id: 'team_700',
credits_monthly: 700,
stop_usd: 332
}
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
it('stays true for a legacy team plan whose payment failed', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: false,
has_funds: true,
billing_status: 'payment_failed',
subscription_tier: 'STANDARD',
plan_slug: 'team-standard-annual'
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(true)
})
it('is false for a team workspace on a personal-tier plan', async () => {
mockTeamWorkspacesEnabled.value = true
mockIsPersonal.value = false
mockBillingStatus.value = {
is_active: true,
has_funds: true,
subscription_tier: 'PRO',
plan_slug: 'pro-monthly'
}
const { initialize, isTeamPlan } = useBillingContext()
await initialize()
expect(isTeamPlan.value).toBe(false)
})
})
})

View File

@@ -6,6 +6,7 @@ import {
getTierFeatures
} from '@/platform/cloud/subscription/constants/tierPricing'
import type { TierKey } from '@/platform/cloud/subscription/constants/tierPricing'
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
import type { SubscriptionDialogOptions } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type {
PreviewSubscribeOptions,
@@ -35,8 +36,8 @@ const LEGACY_TEAM_PLAN_SLUG_PREFIX = 'team-'
*
* - Team workspaces disabled (OSS/Desktop): legacy billing via /customers/*
* - Team workspaces enabled: workspace billing via /api/billing/* for team
* workspaces, and for personal workspaces once consolidated billing is
* enabled; personal workspaces otherwise stay on legacy billing
* workspaces, and for personal workspaces once billing control is enabled;
* personal workspaces otherwise stay on legacy billing
*
* The context automatically initializes when the workspace changes and provides
* a unified interface for subscription status, balance, and billing actions.
@@ -129,6 +130,16 @@ function useBillingContextInternal(): BillingContext {
const isFreeTier = computed(() => subscription.value?.tier === 'FREE')
const freeTierQuota = useFreeTierQuota()
const canRunWorkflows = computed(
() =>
isActiveSubscription.value &&
(!isFreeTier.value ||
!freeTierQuota.quotaEnabled.value ||
freeTierQuota.freeTierExecutionPermitted.value)
)
const isLegacyTeamPlan = computed(
() =>
type.value === 'workspace' &&
@@ -141,6 +152,21 @@ function useBillingContextInternal(): BillingContext {
false)
)
// Plan identity, independent of subscription health: the per-credit Team plan
// carries a credit stop, the retired seat-based ones a `team-` slug. Kept off
// isActiveSubscription on purpose — paused and payment_failed both force
// is_active=false, which is exactly when callers still need to know this is a
// team plan.
const isTeamPlan = computed(
() =>
type.value === 'workspace' &&
(currentTeamCreditStop.value !== null ||
(currentPlanSlug.value
?.toLowerCase()
.startsWith(LEGACY_TEAM_PLAN_SLUG_PREFIX) ??
false))
)
const billingStatus = computed(() =>
toValue(activeContext.value.billingStatus)
)
@@ -191,9 +217,9 @@ function useBillingContextInternal(): BillingContext {
error.value = null
}
// type flips when the team-workspaces or consolidated-billing flag resolves
// from authenticated config, swapping the active backend. Reset then reinit
// on every workspace-id or type change.
// type flips when the team-workspaces or billing-control flag resolves from
// authenticated config, swapping the active backend. Reset then reinit on
// every workspace-id or type change.
watch(
[() => store.activeWorkspace?.id, () => type.value],
async ([newWorkspaceId]) => {
@@ -297,8 +323,10 @@ function useBillingContextInternal(): BillingContext {
isLoading,
error,
isActiveSubscription,
canRunWorkflows,
isFreeTier,
isLegacyTeamPlan,
isTeamPlan,
billingStatus,
subscriptionStatus,
tier,

View File

@@ -5,7 +5,7 @@ import { useBillingRouting } from './useBillingRouting'
const { mockFlags, mockActiveWorkspace } = vi.hoisted(() => ({
mockFlags: {
teamWorkspacesEnabled: false,
consolidatedBillingEnabled: false
billingControlEnabled: false
},
mockActiveWorkspace: {
value: null as { id: string; type: 'personal' | 'team' } | null
@@ -30,7 +30,7 @@ const team = { id: 'w-team', type: 'team' as const }
describe('useBillingRouting', () => {
beforeEach(() => {
mockFlags.teamWorkspacesEnabled = false
mockFlags.consolidatedBillingEnabled = false
mockFlags.billingControlEnabled = false
mockActiveWorkspace.value = personal
})
@@ -44,9 +44,9 @@ describe('useBillingRouting', () => {
expect(shouldUseWorkspaceBilling.value).toBe(false)
})
it('keeps personal on legacy when consolidated billing is disabled', () => {
it('keeps personal on legacy when billing control is disabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockFlags.billingControlEnabled = false
mockActiveWorkspace.value = personal
const { type } = useBillingRouting()
@@ -54,9 +54,9 @@ describe('useBillingRouting', () => {
expect(type.value).toBe('legacy')
})
it('moves personal to workspace billing when consolidated billing is enabled', () => {
it('moves personal to workspace billing when billing control is enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockFlags.billingControlEnabled = true
mockActiveWorkspace.value = personal
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -65,9 +65,9 @@ describe('useBillingRouting', () => {
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces regardless of consolidated billing', () => {
it('uses workspace billing for team workspaces regardless of billing control', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = false
mockFlags.billingControlEnabled = false
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -76,9 +76,9 @@ describe('useBillingRouting', () => {
expect(shouldUseWorkspaceBilling.value).toBe(true)
})
it('uses workspace billing for team workspaces with consolidated billing enabled', () => {
it('uses workspace billing for team workspaces with billing control enabled', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockFlags.billingControlEnabled = true
mockActiveWorkspace.value = team
const { type, shouldUseWorkspaceBilling } = useBillingRouting()
@@ -89,7 +89,7 @@ describe('useBillingRouting', () => {
it('defaults to legacy while the workspace has not loaded', () => {
mockFlags.teamWorkspacesEnabled = true
mockFlags.consolidatedBillingEnabled = true
mockFlags.billingControlEnabled = true
mockActiveWorkspace.value = null
const { type } = useBillingRouting()

View File

@@ -8,7 +8,7 @@ import type { BillingType } from './types'
/**
* Selects the billing backend for the active workspace: legacy user-scoped
* (`/customers/*`) or workspace-scoped (`/api/billing/*`). Personal workspaces
* stay legacy until `consolidatedBillingEnabled`; team workspaces are always
* stay legacy until `billingControlEnabled`; team workspaces are always
* workspace-scoped. The routing matrix is covered in useBillingRouting.test.ts.
*/
export function useBillingRouting() {
@@ -23,7 +23,7 @@ export function useBillingRouting() {
const workspaceType = workspaceStore.activeWorkspace?.type
if (!workspaceType) return 'legacy'
if (workspaceType === 'personal' && !flags.consolidatedBillingEnabled) {
if (workspaceType === 'personal' && !flags.billingControlEnabled) {
return 'legacy'
}

View File

@@ -1,12 +1,19 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, toValue } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodePricing } from '@/composables/node/useNodePricing'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import { trackNodePrice } from '@/renderer/extensions/vueNodes/composables/usePartitionedBadges'
import { app } from '@/scripts/app'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { mapAllNodes } from '@/utils/graphTraversalUtil'
type LinkedWidgetInput = INodeInputSlot & {
_subgraphSlot?: SubgraphInput
@@ -150,3 +157,20 @@ export const usePriceBadge = () => {
updateSubgraphCredits
}
}
export const useCreditsBadgesInGraph = createSharedComposable(() => {
const { isCreditsBadge } = usePriceBadge()
const vueNodeLifecycle = useVueNodeLifecycle()
return computed(() => {
void vueNodeLifecycle.nodeManager.value?.vueNodeData.size
if (!app.graph) return []
return mapAllNodes(app.graph, (node) => {
if (node.isSubgraphNode()) return
const priceBadge = node.badges.find(isCreditsBadge)
if (!priceBadge) return
trackNodePrice(node)
return [node.title, toValue(priceBadge).text, node.id] as const
})
})
})

View File

@@ -0,0 +1,10 @@
import {
breakpointsTailwind,
createSharedComposable,
useBreakpoints
} from '@vueuse/core'
/** `md`+ viewport — the width the onboarding tours need to place their coach-marks. */
export const useDesktopLayout = createSharedComposable(() =>
useBreakpoints(breakpointsTailwind).greaterOrEqual('md')
)

View File

@@ -7,7 +7,7 @@ import {
} from '@/composables/useFeatureFlags'
import * as distributionTypes from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedBillingControlEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
@@ -226,19 +226,19 @@ describe('useFeatureFlags', () => {
expect(flags.teamWorkspacesEnabled).toBe(true)
})
it('consolidatedBillingEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
it('billingControlEnabled override bypasses isCloud and isAuthenticatedConfigLoaded guards', () => {
vi.mocked(distributionTypes).isCloud = false
localStorage.setItem('ff:consolidated_billing_enabled', 'true')
localStorage.setItem('ff:billing_control_enabled', 'true')
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
it('consolidatedBillingEnabled is false off-cloud even without an override', () => {
it('billingControlEnabled is false off-cloud even without an override', () => {
vi.mocked(distributionTypes).isCloud = false
const { flags } = useFeatureFlags()
expect(flags.consolidatedBillingEnabled).toBe(false)
expect(flags.billingControlEnabled).toBe(false)
})
})
@@ -248,7 +248,7 @@ describe('useFeatureFlags', () => {
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
cachedBillingControlEnabled.value = undefined
localStorage.clear()
})
@@ -257,36 +257,36 @@ describe('useFeatureFlags', () => {
remoteConfigState.value = 'unloaded'
remoteConfig.value = {}
cachedTeamWorkspacesEnabled.value = undefined
cachedConsolidatedBillingEnabled.value = undefined
cachedBillingControlEnabled.value = undefined
localStorage.clear()
})
it('returns the cached session value during the auth window', () => {
cachedTeamWorkspacesEnabled.value = false
cachedConsolidatedBillingEnabled.value = true
cachedBillingControlEnabled.value = true
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
it('defaults to false during the auth window when nothing is cached', () => {
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(false)
expect(flags.consolidatedBillingEnabled).toBe(false)
expect(flags.billingControlEnabled).toBe(false)
})
it('prefers authenticated remoteConfig over the server feature fallback', () => {
remoteConfigState.value = 'authenticated'
remoteConfig.value = {
team_workspaces_enabled: true,
consolidated_billing_enabled: true
billing_control_enabled: true
}
vi.mocked(api.getServerFeature).mockReturnValue(false)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
it('falls back to api.getServerFeature when authenticated config omits the flag', () => {
@@ -295,15 +295,14 @@ describe('useFeatureFlags', () => {
vi.mocked(api.getServerFeature).mockImplementation(
(path, defaultValue) => {
if (path === ServerFeatureFlag.TEAM_WORKSPACES_ENABLED) return true
if (path === ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED)
return true
if (path === ServerFeatureFlag.BILLING_CONTROL_ENABLED) return true
return defaultValue
}
)
const { flags } = useFeatureFlags()
expect(flags.teamWorkspacesEnabled).toBe(true)
expect(flags.consolidatedBillingEnabled).toBe(true)
expect(flags.billingControlEnabled).toBe(true)
})
})

View File

@@ -3,7 +3,7 @@ import type { Ref } from 'vue'
import { isCloud, isNightly } from '@/platform/distribution/types'
import {
cachedConsolidatedBillingEnabled,
cachedBillingControlEnabled,
cachedTeamWorkspacesEnabled,
isAuthenticatedConfigLoaded,
remoteConfig
@@ -32,8 +32,10 @@ export enum ServerFeatureFlag {
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile'
BILLING_CONTROL_ENABLED = 'billing_control_enabled',
FREE_TIER_JOB_ALLOWANCE_ENABLED = 'free_tier_job_allowance_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile',
ONBOARDING_TOUR_ENABLED = 'onboarding_tour_enabled'
}
/**
@@ -191,15 +193,25 @@ export function useFeatureFlags() {
)
},
/**
* Whether personal workspaces use the consolidated (workspace-scoped)
* billing flow. While false (default), personal workspaces stay on the
* legacy per-user billing flow; team workspaces are unaffected.
* Whether personal workspaces use the workspace-scoped billing flow. While
* false (default), personal workspaces stay on the legacy per-user billing
* flow; team workspaces are unaffected.
*/
get consolidatedBillingEnabled() {
get billingControlEnabled() {
return resolveAuthGatedFlag(
ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED,
remoteConfig.value.consolidated_billing_enabled,
cachedConsolidatedBillingEnabled
ServerFeatureFlag.BILLING_CONTROL_ENABLED,
remoteConfig.value.billing_control_enabled,
cachedBillingControlEnabled
)
},
get freeTierJobAllowanceEnabled() {
const config = remoteConfig.value as typeof remoteConfig.value & {
free_tier_job_allowance_enabled?: boolean
}
return resolveFlag(
ServerFeatureFlag.FREE_TIER_JOB_ALLOWANCE_ENABLED,
config.free_tier_job_allowance_enabled,
false
)
},
get signupTurnstileMode() {
@@ -208,6 +220,13 @@ export function useFeatureFlags() {
remoteConfig.value.signup_turnstile,
'off'
)
},
get onboardingTourEnabled() {
return resolveFlag(
ServerFeatureFlag.ONBOARDING_TOUR_ENABLED,
remoteConfig.value.onboarding_tour_enabled,
false
)
}
})

View File

@@ -2317,6 +2317,91 @@
"member": "Member"
}
},
"onboardingTour": {
"overlayLabel": "Getting started tour",
"preparing": "Building your template…",
"skip": "Skip",
"back": "Back",
"next": "Next",
"complete": "Finish",
"stepCounter": "{current} of {total}",
"step": {
"upload": {
"t2i": {
"title": "Start with an image",
"body": "This image feeds the workflow. Swap in your own whenever you like."
},
"i2v": {
"title": "Start with an image",
"body": "This picture is your first frame. Swap in your own whenever you like."
},
"image-edit": {
"title": "Start with an image",
"body": "This is the picture you'll edit. Swap in your own whenever you like."
},
"other": {
"title": "Start with an image",
"body": "This image feeds the workflow. Swap in your own whenever you like."
}
},
"prompt": {
"t2i": {
"title": "Describe what you want",
"body": "Your image gets built from this description. Try anything you like."
},
"i2v": {
"title": "Describe how it should move",
"body": "The image sets the scene. This tells it what happens next."
},
"image-edit": {
"title": "Say what to change",
"body": "Your image stays as it is. Describe what you want different."
},
"other": {
"title": "Tell it what to make",
"body": "This is your prompt. Change it to change your result."
}
},
"run": {
"title": "Run your workflow",
"body": "Press Run to start generating your result"
},
"result": {
"image": {
"title": "And there it is",
"body": "Your new image lands right here — ready to download or share."
},
"video": {
"title": "And there it is",
"body": "Your new video lands right here — ready to download or share."
}
}
},
"generating": "Generating…",
"gettingStarted": {
"title": "Get started with graph",
"subtitle": "Start a workflow from scratch or load one that's ready to go.",
"loadFailed": "Failed to load templates",
"screenLabel": "Get started with graph",
"tabsLabel": "Getting started options",
"tabs": {
"templates": "Templates",
"tutorials": "Tutorials"
},
"tutorials": {
"interfaceOverview": "Interface overview",
"textToImage": "Text to image",
"imageToImage": "Image to image",
"inpaint": "Inpainting"
}
},
"nudge": {
"title": "That was one of hundreds",
"body": "You just made your first. Explore what else you can build.",
"dismiss": "Not now",
"explore": "Explore templates"
}
},
"auth": {
"apiKey": {
"title": "API Key",
@@ -2473,6 +2558,11 @@
},
"credits": {
"activity": "Activity",
"insufficient": {
"memberTitle": "This workspace is out of credits",
"memberDescription": "Your team has used all its credits. Your workspace admins need to add more credits to run workflows.",
"memberCta": "Ok, got it"
},
"credits": "Credits",
"yourCreditBalance": "Your credit balance",
"purchaseCredits": "Purchase Credits",
@@ -2882,6 +2972,32 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"billingStatus": {
"warning": {
"title": "Payment declined",
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
"bodyNoDate": "Your last payment didn't go through. Update payment to avoid a pause."
},
"paused": {
"title": "Subscription paused",
"body": "This workspace's subscription is paused. Update payment to resume.",
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method."
},
"outOfCredits": {
"title": "Out of credits",
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
"memberBody": "Your team has used all its credits. Your workspace admins need to add more credits to continue generating.",
"addCredits": "Add credits",
"dismiss": "Dismiss"
},
"ending": {
"title": "Your team plan ends on {date}",
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
"reactivate": "Reactivate plan"
},
"updatePayment": "Update payment"
},
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
@@ -3556,6 +3672,9 @@
"dockToTop": "Dock to top",
"feedback": "Feedback",
"feedbackTooltip": "Feedback",
"freeTierRuns": "{available} / {MAX_AVAILABLE} runs left",
"freeTierRunsExhausted": "No runs left",
"freeTierPartner": "Partner nodes need a paid plan",
"share": "Share",
"shareTooltip": "Share workflow"
},
@@ -4548,5 +4667,37 @@
"training": "Training…",
"processingVideo": "Processing video…",
"running": "Running…"
},
"onboardingCoachmarks": {
"stepLabel": "Step {current} of {total}",
"skip": "Skip",
"next": "Next",
"back": "Back",
"done": "Done",
"loadError": "Something went wrong showing this tour",
"appMode": {
"replay": "Take a tour of App Mode",
"landing": {
"title": "Welcome to Apps",
"body": "A quick tour of the essentials, in about a minute. We'll show you where to add inputs, run your app, and find your results.",
"primary": "Start tutorial"
},
"inputs": {
"title": "Add your inputs",
"body": "Add what you want to work with. Your inputs are what the app turns into results."
},
"run": {
"title": "Run your app",
"body": "Happy with your inputs? Hit Run and your result appears in the center the moment it's ready."
},
"outputs": {
"title": "Get your results",
"body": "Your finished results show up here in the center. Download them, or tweak an input and run again."
},
"assets": {
"title": "Find all your assets",
"body": "Every generation and import lives in Media Assets. Open it anytime to browse, download, or reuse past work."
}
}
}
}

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFreeTierQuota } from '@/platform/cloud/subscription/composables/useFreeTierQuota'
const DOT_COLORS = [
'bg-destructive-background',
'bg-warning-background',
'bg-success-background'
]
const { showSubscriptionDialog } = useBillingContext()
const { t } = useI18n()
const { available, hasInvalidNodes, maxAvailable, quotaEnabled } =
useFreeTierQuota()
const dotColor = computed(() => {
const ratio = maxAvailable.value ? available.value / maxAvailable.value : 0
return DOT_COLORS[
Math.min(Math.floor(ratio * DOT_COLORS.length), DOT_COLORS.length - 1)
]
})
const label = computed(() =>
available.value === 0
? t('actionbar.freeTierRunsExhausted')
: t('actionbar.freeTierRuns', {
available: available.value,
MAX_AVAILABLE: maxAvailable.value
})
)
</script>
<template>
<div
v-if="quotaEnabled"
class="mt-2 w-full cursor-pointer border-t border-border-subtle bg-comfy-menu-bg px-4 pt-2 select-none"
data-testid="free-tier-quota"
@click="showSubscriptionDialog({ reason: 'free_tier_quota' })"
>
<div
v-if="hasInvalidNodes"
class="flex w-full items-center justify-center gap-2"
>
<i class="icon-[comfy--credits] bg-amber-400" />
{{ t('actionbar.freeTierPartner') }}
</div>
<div v-else class="flex w-full items-center justify-between">
<div class="flex gap-2" :aria-label="label" role="img">
<div
v-for="index in maxAvailable"
:key="index"
:class="
cn(
'size-1.5 rounded-full',
index > available ? 'bg-secondary-background-selected' : dotColor
)
"
/>
</div>
<div v-text="label" />
</div>
</div>
</template>

View File

@@ -8,10 +8,19 @@ const mockDialogService = {
showTopUpCreditsDialog: vi.fn()
}
const mockBilling = {
fetchStatus: vi.fn(),
fetchBalance: vi.fn()
}
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => mockDialogService)
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => mockBilling)
}))
describe('useAccountPreconditionDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -55,4 +64,18 @@ describe('useAccountPreconditionDialog', () => {
mockDialogService.showSubscriptionRequiredDialog
).not.toHaveBeenCalled()
})
it('refreshes the billing snapshot on a credit precondition so exhausted-state surfaces converge', () => {
useAccountPreconditionDialog().open('credits')
expect(mockBilling.fetchStatus).toHaveBeenCalledTimes(1)
expect(mockBilling.fetchBalance).toHaveBeenCalledTimes(1)
})
it('does not touch billing state for non-credit preconditions', () => {
useAccountPreconditionDialog().open('subscription')
expect(mockBilling.fetchStatus).not.toHaveBeenCalled()
expect(mockBilling.fetchBalance).not.toHaveBeenCalled()
})
})

View File

@@ -1,3 +1,4 @@
import { useBillingContext } from '@/composables/billing/useBillingContext'
import type { AccountPrecondition } from '@/platform/errorCatalog/accountPreconditionRouting'
import { useDialogService } from '@/services/dialogService'
@@ -28,11 +29,19 @@ export function useAccountPreconditionDialog() {
reason: 'subscription_required'
})
return
case 'credits':
case 'credits': {
// The server just declared the balance exhausted; there is no push or
// polling for billing state, so refresh it here to converge
// hasFunds-keyed surfaces such as the credits-exhausted banner. The
// refresh is best-effort: allSettled keeps a flaky billing API from
// surfacing as unhandled rejections.
const { fetchStatus, fetchBalance } = useBillingContext()
void Promise.allSettled([fetchStatus(), fetchBalance()])
void dialogService.showTopUpCreditsDialog({
isInsufficientCredits: true
})
return
}
}
}

View File

@@ -0,0 +1,45 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useCreditsBadgesInGraph } from '@/composables/node/usePriceBadge'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
export const useFreeTierQuota = createSharedComposable(function () {
const { flags } = useFeatureFlags()
const creditsBadges = useCreditsBadgesInGraph()
const available = ref(0)
const maxAvailable = ref(0)
watch(
() => remoteConfig.value.free_tier_balance?.remaining,
(val) => (available.value = val ?? 0),
{ immediate: true }
)
watch(
() => remoteConfig.value.free_tier_balance?.allowance,
(val) => (maxAvailable.value = val ?? 0),
{ immediate: true }
)
const quotaEnabled = computed(
() => flags.freeTierJobAllowanceEnabled && maxAvailable.value > 0
)
const hasInvalidNodes = computed(() => creditsBadges.value.length > 0)
const freeTierExecutionPermitted = computed(
() => !hasInvalidNodes.value && quotaEnabled.value && available.value > 0
)
function trackRun() {
if (available.value > 0) available.value--
}
return {
available,
freeTierExecutionPermitted,
hasInvalidNodes,
maxAvailable,
quotaEnabled,
trackRun
}
})

View File

@@ -266,6 +266,21 @@ describe('useSubscriptionDialog', () => {
expect(mockTrackSubscription).not.toHaveBeenCalled()
})
it('shows the read-only member dialog for out-of-credits too, not the pricing table', () => {
mockShouldUseWorkspaceBilling.value = true
mockIsInPersonalWorkspace.value = false
mockCanManageSubscription.value = false
const { showPricingTable } = useSubscriptionDialog()
showPricingTable({ reason: 'out_of_credits' })
expect(mockShowLayoutDialog).toHaveBeenCalledTimes(1)
const props = mockShowLayoutDialog.mock.calls[0][0].props
expect(props).toHaveProperty('onClose')
expect(props).not.toHaveProperty('reason')
expect(props).not.toHaveProperty('initialPlanMode')
})
it('does not track on non-cloud', () => {
mockIsCloud.value = false
const { showPricingTable } = useSubscriptionDialog()

View File

@@ -13,6 +13,9 @@ const DIALOG_KEY = 'subscription-required'
const FREE_TIER_DIALOG_KEY = 'free-tier-info'
const RESUME_PRICING_KEY = 'comfy:resume-team-pricing'
/** Dialog keys the subscription/upgrade flow opens; callers gate around these. */
export const UPGRADE_DIALOG_KEYS = [FREE_TIER_DIALOG_KEY, DIALOG_KEY] as const
export interface SubscriptionDialogOptions {
reason?: PaymentIntentSource
/**
@@ -55,12 +58,12 @@ export const useSubscriptionDialog = () => {
// Members can't manage the workspace subscription, so a blocked run shows a
// small read-only "ask your owner to reactivate" modal instead of the
// pricing table. Out-of-credits still routes everyone to the credits flow.
// pricing table — including out-of-credits, whose member recovery path is
// also owner-only (FE-1246).
if (
shouldUseWorkspaceBilling.value &&
!workspaceStore.isInPersonalWorkspace &&
!permissions.value.canManageSubscription &&
options?.reason !== 'out_of_credits'
!permissions.value.canManageSubscription
) {
dialogService.showLayoutDialog({
key: DIALOG_KEY,
@@ -88,9 +91,9 @@ export const useSubscriptionDialog = () => {
} as const
// Jun-5 model: a single unified pricing table (personal/team plan toggle on
// one workspace) for workspaces on the consolidated billing flow. Replaces
// the old personal-vs-team workspace fork. Personal workspaces still on the
// legacy flow (consolidated billing disabled) get the legacy table.
// one workspace) for workspaces on the workspace-scoped billing flow.
// Replaces the old personal-vs-team workspace fork. Personal workspaces
// still on the legacy flow (billing control disabled) get the legacy table.
if (shouldUseWorkspaceBilling.value) {
// Existing per-member (legacy) team subscribers keep the old tier-based
// team table; the unified credit-slider table is for everyone else.

View File

@@ -73,6 +73,33 @@ describe('resolveAccountPrecondition', () => {
).toBe('credits')
})
it('classifies the submit-time 402 body by its insufficient_credits type regardless of message', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'insufficient_credits',
exceptionMessage: 'Workspace balance exhausted'
})
).toBe('credits')
})
it('classifies the team submit-time 429 (PAYMENT_REQUIRED / insufficient credits) as a credits precondition', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'PAYMENT_REQUIRED',
exceptionMessage: 'Insufficient credits to queue workflows'
})
).toBe('credits')
})
it('keeps the team submit-time 429 for an inactive subscription on the subscription precondition', () => {
expect(
resolveAccountPrecondition({
exceptionType: 'PAYMENT_REQUIRED',
exceptionMessage: 'Subscription required to queue workflows'
})
).toBe('subscription')
})
it('returns undefined for an ordinary workflow error', () => {
expect(
resolveAccountPrecondition({

View File

@@ -33,7 +33,13 @@ const INSUFFICIENT_CREDITS_MESSAGES = new Set([
'Payment Required: Please add credits to your account to use this node.'
])
const WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES = new Set([
'Payment Required: Please add credits to your workspace to continue.'
// Execution-time (pre-GPU) WebSocket failure for a queued team job.
'Payment Required: Please add credits to your workspace to continue.',
// Submit-time 429 rejection for a team workspace out of credits
// (checkTeamWorkspaceSubscription). It shares the PAYMENT_REQUIRED error type
// with the subscription-required rejection, so it can only be told apart by
// message.
'Insufficient credits to queue workflows'
])
const SUBSCRIPTION_REQUIRED_MESSAGES = new Set([
'Workspace has no active subscription. Please subscribe to a plan to continue.',
@@ -243,8 +249,12 @@ const RUNTIME_MATCH_RULES: RuntimeMatchRule[] = [
resolve: () => catalogMatch(WORKSPACE_INSUFFICIENT_CREDITS_CATALOG_ID)
},
{
// 'insufficient_credits' is the error.type BE-2866 will emit on the
// personal submit-time 402; the team submit path is a 429 matched by
// message in WORKSPACE_INSUFFICIENT_CREDITS_MESSAGES above.
matches: (info, message) =>
info.exceptionType === 'InsufficientFundsError' ||
info.exceptionType === 'insufficient_credits' ||
INSUFFICIENT_CREDITS_MESSAGES.has(message),
resolve: () => catalogMatch(INSUFFICIENT_CREDITS_CATALOG_ID)
},

View File

@@ -0,0 +1,67 @@
import { cleanup, render, screen } from '@testing-library/vue'
import { afterEach, describe, expect, it } from 'vitest'
import { h } from 'vue'
import CoachmarkCard from './CoachmarkCard.vue'
afterEach(cleanup)
describe('CoachmarkCard', () => {
it('renders the title, message and subtitle', () => {
render(CoachmarkCard, {
props: {
title: 'This is your canvas',
message: 'Scroll to zoom.',
subtitle: 'Step 1 of 3'
}
})
expect(screen.getByRole('heading')).toHaveTextContent('This is your canvas')
expect(screen.getByText('Scroll to zoom.')).toBeTruthy()
expect(screen.getByText('Step 1 of 3')).toBeTruthy()
})
it('applies titleId to the heading for aria-labelledby wiring', () => {
render(CoachmarkCard, {
props: { title: 'Heading', message: 'M', titleId: 'title-1' }
})
expect(screen.getByRole('heading').id).toBe('title-1')
})
it('applies messageId to the message for aria-describedby wiring', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'Body copy', messageId: 'desc-1' }
})
expect(screen.getByText('Body copy').id).toBe('desc-1')
})
it('omits the subtitle when not provided', () => {
render(CoachmarkCard, { props: { title: 'T', message: 'M' } })
expect(screen.queryByText('Step 1 of 3')).toBeNull()
})
it('renders the image when an image src is given', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'M', image: '/foo.png' }
})
expect(screen.getByAltText('')).toHaveAttribute('src', '/foo.png')
})
it('renders an image slot in place of the default image', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'M' },
slots: { image: () => h('img', { src: '/slot.png', alt: 'preview' }) }
})
expect(screen.getByRole('img', { name: 'preview' })).toHaveAttribute(
'src',
'/slot.png'
)
})
it('renders the actions slot', () => {
render(CoachmarkCard, {
props: { title: 'T', message: 'M' },
slots: { actions: () => h('button', 'Next') }
})
expect(screen.getByRole('button', { name: 'Next' })).toBeTruthy()
})
})

View File

@@ -0,0 +1,50 @@
<template>
<div
class="flex w-full flex-col items-start justify-center gap-3 rounded-2xl bg-secondary-background p-4 drop-shadow-[1px_1px_8px_rgba(0,0,0,0.4)]"
>
<div
v-if="image || $slots.image"
class="flex h-[146px] flex-col items-start justify-center gap-4 self-stretch overflow-hidden rounded-xl bg-base-background"
>
<slot name="image">
<img v-if="image" :src="image" alt="" class="size-full object-cover" />
</slot>
</div>
<div class="flex flex-col items-end justify-end gap-6 self-stretch">
<div class="flex flex-col items-start gap-2 self-stretch">
<p
v-if="subtitle"
:id="subtitleId"
class="m-0 text-xs/normal text-base-foreground"
>
{{ subtitle }}
</p>
<h3
:id="titleId"
class="m-0 text-base/normal font-semibold text-base-foreground"
>
{{ title }}
</h3>
<p :id="messageId" class="m-0 text-sm/normal text-muted-foreground">
{{ message }}
</p>
</div>
<div v-if="$slots.actions" class="flex w-full items-center gap-3">
<slot name="actions" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
const { title, titleId, message, subtitle, subtitleId, image, messageId } =
defineProps<{
title: string
titleId?: string
message: string
subtitle?: string
subtitleId?: string
image?: string
messageId?: string
}>()
</script>

View File

@@ -0,0 +1,67 @@
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import CoachmarkLanding from './CoachmarkLanding.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderLanding() {
return render(CoachmarkLanding, {
props: {
title: 'Welcome to Apps',
message: 'A quick tour of the essentials.',
primaryLabel: 'Start tutorial',
skipLabel: 'Skip',
waitingForTarget: false
},
global: { plugins: [i18n] }
})
}
describe('CoachmarkLanding', () => {
afterEach(cleanup)
it('renders a modal backdrop behind the landing card', async () => {
renderLanding()
expect(await screen.findByTestId('coach-landing-overlay')).toBeTruthy()
})
it('renders the title and message', async () => {
renderLanding()
expect(await screen.findByText('Welcome to Apps')).toBeTruthy()
expect(screen.getByText('A quick tour of the essentials.')).toBeTruthy()
})
it('emits start when the primary action is clicked', async () => {
const user = userEvent.setup()
const { emitted } = renderLanding()
await user.click(
await screen.findByRole('button', { name: 'Start tutorial' })
)
expect(emitted().start).toHaveLength(1)
})
it('emits skip when Skip is clicked', async () => {
const user = userEvent.setup()
const { emitted } = renderLanding()
await user.click(await screen.findByRole('button', { name: 'Skip' }))
expect(emitted().skip).toHaveLength(1)
})
it('emits skip when Escape is pressed', async () => {
const user = userEvent.setup()
const { emitted } = renderLanding()
await screen.findByText('Welcome to Apps')
await user.keyboard('{Escape}')
// The explicit listener and Reka's own dismiss may both fire here.
expect(emitted().skip?.length).toBeGreaterThanOrEqual(1)
})
})

View File

@@ -0,0 +1,120 @@
<template>
<!-- The Dialog wrapper boolean-casts an absent `modal` to false, which
suppresses the DialogOverlay backdrop; it must be passed explicitly. -->
<Dialog :open="true" modal @update:open="(value) => !value && emit('skip')">
<DialogPortal>
<DialogOverlay
v-reka-z-index
data-testid="coach-landing-overlay"
class="bg-coach-scrim"
/>
<DialogContent
v-reka-z-index
data-testid="coach-landing"
class="w-[800px] max-w-[calc(100vw-2.5rem)] overflow-hidden rounded-2xl border-border-default bg-secondary-background p-0 shadow-[0_24px_80px_rgba(0,0,0,0.85)] md:h-fit md:min-h-100 md:max-w-[800px] md:flex-row"
@pointer-down-outside.prevent
@open-auto-focus="onOpenAutoFocus"
>
<DialogClose as-child>
<Button
variant="muted-textonly"
size="icon"
:aria-label="t('g.close')"
class="absolute top-3 right-3 z-20"
>
<i class="icon-[lucide--x]" />
</Button>
</DialogClose>
<div
class="flex aspect-video items-center justify-center bg-base-background md:aspect-auto md:w-1/2"
>
<img
v-if="image"
:src="image"
alt=""
class="size-full object-cover"
/>
</div>
<div
class="flex flex-col items-start justify-between self-stretch px-15 pt-15 pb-10 md:w-1/2"
>
<div class="flex flex-col gap-2.5">
<DialogTitle
class="m-0 text-[28px] leading-normal font-medium text-base-foreground"
>
{{ title }}
</DialogTitle>
<DialogDescription
class="flex flex-col items-start gap-2 self-stretch text-base/5 text-muted-foreground"
>
{{ message }}
</DialogDescription>
</div>
<div class="flex w-full items-center justify-end gap-2">
<Button variant="secondary" size="lg" @click="emit('skip')">
{{ skipLabel }}
</Button>
<Button
ref="startButtonRef"
variant="inverted"
size="lg"
class="grow"
:disabled="waitingForTarget"
@click="emit('start')"
>
{{ primaryLabel }}
</Button>
</div>
</div>
</DialogContent>
</DialogPortal>
</Dialog>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Dialog from '@/components/ui/dialog/Dialog.vue'
import DialogClose from '@/components/ui/dialog/DialogClose.vue'
import DialogContent from '@/components/ui/dialog/DialogContent.vue'
import DialogDescription from '@/components/ui/dialog/DialogDescription.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import Button from '@/components/ui/button/Button.vue'
import { vRekaZIndex } from '@/components/dialog/vRekaZIndex'
const { title, message, image, primaryLabel, skipLabel, waitingForTarget } =
defineProps<{
title: string
message: string
image?: string
primaryLabel: string
skipLabel: string
waitingForTarget: boolean
}>()
const emit = defineEmits<{
start: []
skip: []
}>()
const { t } = useI18n()
// The global keybinding handler preventDefaults Escape before Reka's
// DismissableLayer sees it, so Reka skips its own dismiss; do it explicitly.
useEventListener(document, 'keydown', (e) => {
if (e.key === 'Escape') emit('skip')
})
const startButtonRef = useTemplateRef('startButtonRef')
// Land focus on the primary action, not the close button Reka would pick.
function onOpenAutoFocus(event: Event) {
event.preventDefault()
const el = startButtonRef.value?.$el as HTMLButtonElement | undefined
el?.focus()
}
</script>

View File

@@ -0,0 +1,127 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, reactive, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import TourOverlay from './TourOverlay.vue'
import type { CoachStep, EntryPath } from './onboardingTours'
import { useOnboardingTourStore } from './onboardingTourStore'
vi.mock('./onboardingTourStore', () => ({ useOnboardingTourStore: vi.fn() }))
function makeTourState() {
return {
activeTour: ref<EntryPath>('appMode'),
step: ref<CoachStep | null>(null),
title: ref('Canvas title'),
body: ref('Canvas body'),
isLast: ref(false),
primaryLabel: ref('Next'),
skipLabel: ref('Skip'),
countedStepIdx: ref(0),
countedStepsTotal: ref(0),
waitingForTarget: ref(false),
next: vi.fn(),
skip: vi.fn()
}
}
// Stubbed so the suite covers only TourOverlay's branching and intent wiring.
vi.mock('./TourSpotlight.vue', () => ({
default: defineComponent({
emits: ['advance', 'skip'],
setup(_, { emit }) {
return () =>
h('div', { 'data-testid': 'spotlight' }, [
h('button', { onClick: () => emit('advance') }, 'advance'),
h('button', { onClick: () => emit('skip') }, 'skip')
])
}
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
let s: ReturnType<typeof makeTourState>
const spotlightStep: CoachStep = {
name: 'run',
placement: 'right'
}
function landingStep(): CoachStep {
return { name: 'landing', placement: 'center', landing: true }
}
function renderOverlay() {
return render(TourOverlay, { global: { plugins: [i18n] } })
}
describe('TourOverlay', () => {
beforeEach(() => {
s = makeTourState()
vi.mocked(useOnboardingTourStore).mockReturnValue(fromPartial(reactive(s)))
})
afterEach(cleanup)
it('renders nothing when no tour step is active', () => {
renderOverlay()
expect(screen.queryByTestId('spotlight')).toBeNull()
expect(screen.queryByRole('dialog')).toBeNull()
})
it('renders the spotlight for a non-landing step and wires its intents', async () => {
const user = userEvent.setup()
s.step.value = spotlightStep
renderOverlay()
expect(screen.getByTestId('spotlight')).toBeTruthy()
await user.click(screen.getByRole('button', { name: 'advance' }))
expect(s.next).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: 'skip' }))
expect(s.skip).toHaveBeenCalledOnce()
})
it('renders the landing step and starts the tour on its primary action', async () => {
const user = userEvent.setup()
s.step.value = landingStep()
s.primaryLabel.value = 'Start tutorial'
renderOverlay()
await user.click(
await screen.findByRole('button', { name: 'Start tutorial' })
)
expect(s.next).toHaveBeenCalledOnce()
})
it('disables the landing primary action while waiting for a deferred target', async () => {
s.step.value = landingStep()
s.primaryLabel.value = 'Start tutorial'
s.waitingForTarget.value = true
renderOverlay()
expect(
await screen.findByRole('button', { name: 'Start tutorial' })
).toBeDisabled()
})
it('ends the tour when the landing is dismissed', async () => {
const user = userEvent.setup()
s.step.value = landingStep()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Skip' }))
expect(s.skip).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,46 @@
<template>
<CoachmarkLanding
v-if="isRegistryTour && tour.step?.landing"
:title="tour.title"
:message="tour.body"
:image="tour.step.image"
:primary-label="tour.primaryLabel"
:skip-label="tour.skipLabel"
:waiting-for-target="tour.waitingForTarget"
@start="tour.next"
@skip="tour.skip"
/>
<TourSpotlight
v-else-if="isRegistryTour && tour.step"
:step="tour.step"
:title="tour.title"
:body="tour.body"
:is-last="tour.isLast"
:can-go-back="tour.canGoBack"
:primary-label="tour.primaryLabel"
:skip-label="tour.skipLabel"
:back-label="tour.backLabel"
:counted-step-idx="tour.countedStepIdx"
:counted-steps-total="tour.countedStepsTotal"
:waiting-for-target="tour.waitingForTarget"
@advance="tour.next"
@back="tour.back"
@skip="tour.skip"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import CoachmarkLanding from './CoachmarkLanding.vue'
import TourSpotlight from './TourSpotlight.vue'
import { TOURS } from './onboardingTours'
import { useOnboardingTourStore } from './onboardingTourStore'
const tour = useOnboardingTourStore()
// firstRun renders its own overlay; here, render only registry-backed tours.
const isRegistryTour = computed(
() => tour.activeTour != null && tour.activeTour in TOURS
)
</script>

View File

@@ -0,0 +1,147 @@
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import type { ComponentProps } from 'vue-component-type-helpers'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import { clearCoachmarks } from './coachmarkRegistry'
import TourSpotlight from './TourSpotlight.vue'
import type { CoachStep } from './onboardingTours'
vi.mock('@primeuix/utils/zindex', () => ({
ZIndex: { set: vi.fn(), clear: vi.fn() }
}))
import { ZIndex } from '@primeuix/utils/zindex'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function spotlightStep(overrides: Partial<CoachStep> = {}): CoachStep {
return { name: 'run', placement: 'right', ...overrides }
}
const baseProps = {
title: 'Run your app',
body: 'Press to run',
isLast: false,
canGoBack: false,
primaryLabel: 'Next',
skipLabel: 'Skip',
backLabel: 'Back',
countedStepIdx: 0,
countedStepsTotal: 1,
waitingForTarget: false
}
function renderSpotlight(
props: Partial<ComponentProps<typeof TourSpotlight>> = {}
) {
return render(TourSpotlight, {
props: { step: spotlightStep(), ...baseProps, ...props },
global: { plugins: [i18n] }
})
}
describe('TourSpotlight', () => {
afterEach(() => {
cleanup()
clearCoachmarks()
document.body.replaceChildren()
vi.useRealTimers()
})
it('renders the spotlight and card for a step', () => {
renderSpotlight()
expect(screen.getByTestId('coach-spotlight')).toBeTruthy()
expect(screen.getByRole('dialog', { name: 'Run your app' })).toBeTruthy()
expect(screen.getByText('Press to run')).toBeTruthy()
expect(screen.getByText('Step 1 of 1')).toBeTruthy()
})
it('hides the Skip button on the last step', () => {
renderSpotlight({ isLast: true })
expect(screen.queryByRole('button', { name: 'Skip' })).toBeNull()
expect(screen.getByRole('button', { name: 'Next' })).toBeTruthy()
})
it('shows Back and emits back when there is a previous step', async () => {
const user = userEvent.setup()
const { emitted } = renderSpotlight({ canGoBack: true })
await user.click(screen.getByRole('button', { name: 'Back' }))
expect(emitted().back).toHaveLength(1)
})
it('hides Back on the first step', () => {
renderSpotlight({ canGoBack: false })
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull()
})
it('claims the modal stack on mount and releases it on unmount', async () => {
vi.mocked(ZIndex.set).mockClear()
vi.mocked(ZIndex.clear).mockClear()
const { unmount } = renderSpotlight()
await nextTick()
await nextTick()
expect(ZIndex.set).toHaveBeenCalled()
unmount()
expect(ZIndex.clear).toHaveBeenCalled()
})
it('re-claims the modal stack per step without leaking entries', async () => {
vi.mocked(ZIndex.set).mockClear()
vi.mocked(ZIndex.clear).mockClear()
const { rerender, unmount } = renderSpotlight()
await nextTick()
await nextTick()
await rerender({ step: spotlightStep({ placement: 'left' }) })
await nextTick()
await nextTick()
unmount()
// Sets must pair with clears or entries leak; the +1 is the unmount clear.
expect(vi.mocked(ZIndex.clear).mock.calls.length).toBe(
vi.mocked(ZIndex.set).mock.calls.length + 1
)
expect(ZIndex.set).toHaveBeenCalled()
})
it('emits advance on the primary button and skip on the secondary', async () => {
const user = userEvent.setup()
const { emitted } = renderSpotlight()
await user.click(screen.getByRole('button', { name: 'Next' }))
expect(emitted().advance).toHaveLength(1)
await user.click(screen.getByRole('button', { name: 'Skip' }))
expect(emitted().skip).toHaveLength(1)
})
it('emits skip when Escape is pressed', async () => {
const user = userEvent.setup()
const { emitted } = renderSpotlight()
await user.keyboard('{Escape}')
expect(emitted().skip).toHaveLength(1)
})
it('disables the primary button while waiting for a deferred target', () => {
renderSpotlight({ waitingForTarget: true })
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled()
})
it('hides the spotlight and dims via the blocker for a step with no target', () => {
renderSpotlight({ step: spotlightStep({ placement: 'center' }) })
expect(screen.getByTestId('coach-spotlight').style.opacity).toBe('0')
})
})

View File

@@ -0,0 +1,239 @@
<template>
<div ref="overlayRef" class="pointer-events-none fixed inset-0">
<div
:class="
cn(
'pointer-events-auto absolute inset-0',
!targetRect && 'bg-coach-scrim'
)
"
/>
<div
aria-hidden="true"
data-testid="coach-spotlight"
class="pointer-events-none absolute rounded-xl shadow-[0_0_0_9999px_var(--color-coach-scrim)] outline-2 outline-coach-ring motion-safe:transition-[left,top,width,height,opacity] motion-safe:duration-300"
:style="spotlightStyle"
/>
<FocusScope
as-child
:trapped="!waitingForTarget"
loop
@mount-auto-focus.prevent
>
<div
ref="cardRef"
role="dialog"
aria-modal="true"
:aria-labelledby="titleId"
:aria-describedby="`${subtitleId} ${bodyId}`"
class="pointer-events-auto absolute max-h-[calc(100vh-var(--comfy-topbar-height)-2rem)] overflow-y-auto motion-safe:transition-[left,top] motion-safe:duration-300"
:style="cardStyle"
>
<CoachmarkCard
:subtitle="
t('onboardingCoachmarks.stepLabel', {
current: countedStepIdx + 1,
total: countedStepsTotal
})
"
:subtitle-id="subtitleId"
:title
:title-id="titleId"
:message="body"
:message-id="bodyId"
:image="step.image"
>
<template #actions>
<Button
v-if="showSkip"
variant="textonly"
size="md"
@click="emit('skip')"
>
{{ skipLabel }}
</Button>
<div class="ml-auto flex items-center gap-3">
<Button
v-if="canGoBack"
variant="secondary"
size="md"
class="border border-solid border-border-default"
@click="emit('back')"
>
<i class="icon-[lucide--arrow-left]" />
{{ backLabel }}
</Button>
<Button
ref="primaryButton"
variant="inverted"
size="md"
:disabled="waitingForTarget"
@click="emit('advance')"
>
{{ primaryLabel }}
<i v-if="!isLast" class="icon-[lucide--arrow-right]" />
</Button>
</div>
</template>
</CoachmarkCard>
</div>
</FocusScope>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener, useWindowSize } from '@vueuse/core'
import { ZIndex } from '@primeuix/utils/zindex'
import { FocusScope } from 'reka-ui'
import {
computed,
nextTick,
onBeforeUnmount,
ref,
useId,
useTemplateRef,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import { MODAL_Z_BASE, MODAL_Z_KEY } from '@/components/dialog/vRekaZIndex'
import Button from '@/components/ui/button/Button.vue'
import CoachmarkCard from './CoachmarkCard.vue'
import {
CARD_WIDTH,
SPOTLIGHT_PAD,
VIEWPORT_MARGIN,
clampSpotlight,
noTargetCardLeft
} from './coachmarkLayout'
import type { CoachStep } from './onboardingTours'
import { useCoachmarkTarget } from './useCoachmarkTarget'
const {
step,
title,
body,
isLast,
canGoBack,
primaryLabel,
skipLabel,
backLabel,
countedStepIdx,
countedStepsTotal,
waitingForTarget
} = defineProps<{
step: CoachStep
title: string
body: string
isLast: boolean
canGoBack: boolean
primaryLabel: string
skipLabel: string
backLabel: string
countedStepIdx: number
countedStepsTotal: number
waitingForTarget: boolean
}>()
const emit = defineEmits<{
advance: []
back: []
skip: []
}>()
const { t } = useI18n()
const bodyId = useId()
const subtitleId = useId()
const titleId = useId()
const overlayRef = ref<HTMLElement | null>(null)
const cardRef = ref<HTMLElement | null>(null)
const { width: windowWidth, height: windowHeight } = useWindowSize()
const { targetRect, targetEl, floatingStyles, isPositioned } =
useCoachmarkTarget(() => step, cardRef)
// Last step's "Done" already dismisses, so hide Skip there.
const showSkip = computed(() => !isLast)
const primaryButton =
useTemplateRef<InstanceType<typeof Button>>('primaryButton')
async function focusPrimary() {
await nextTick()
const el = primaryButton.value?.$el as HTMLElement | undefined
el?.focus()
}
useEventListener(
document,
'keydown',
(e: KeyboardEvent) => {
if (e.key !== 'Escape') return
e.preventDefault()
e.stopPropagation()
emit('skip')
},
{ capture: true }
)
async function raiseOverlay() {
await nextTick()
const el = overlayRef.value
if (!el) return
// ZIndex.set pushes a fresh entry into the shared modal sequence on every
// call, so clear the previous one or per-step re-raises leak entries.
ZIndex.clear(el)
ZIndex.set(MODAL_Z_KEY, el, MODAL_Z_BASE)
}
watch(
() => step,
() => void raiseOverlay(),
{ immediate: true }
)
watch(
[() => step, () => waitingForTarget],
() => {
if (!waitingForTarget) void focusPrimary()
},
{ immediate: true }
)
onBeforeUnmount(() => {
if (overlayRef.value) ZIndex.clear(overlayRef.value)
})
function viewport() {
return { width: windowWidth.value, height: windowHeight.value }
}
const spotlightStyle = computed(() => {
const r = targetRect.value
if (!r) return { opacity: '0' }
return { ...clampSpotlight(r, SPOTLIGHT_PAD, viewport()), opacity: '1' }
})
const cardStyle = computed(() => {
const width = `${CARD_WIDTH}px`
const maxWidth = `calc(100vw - ${VIEWPORT_MARGIN * 2}px)`
if (!targetEl.value) {
return {
width,
maxWidth,
left: `${noTargetCardLeft(windowWidth.value)}px`,
top: '30%'
}
}
// Hidden until Floating UI positions it, avoiding a first-frame jump.
return {
...floatingStyles.value,
width,
maxWidth,
opacity: isPositioned.value ? '1' : '0'
}
})
</script>

View File

@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
CARD_GAP,
clampSpotlight,
noTargetCardLeft,
topSafeInset
} from './coachmarkLayout'
const VIEWPORT = { width: 1000, height: 800 }
describe('clampSpotlight', () => {
it('grows the target rect by the pad on every side', () => {
const r = new DOMRect(100, 100, 50, 40)
expect(clampSpotlight(r, 8, VIEWPORT)).toEqual({
left: '92px',
top: '92px',
width: '66px',
height: '56px'
})
})
it('clamps the near edges to the viewport inset', () => {
const r = new DOMRect(0, 0, 10, 10)
expect(clampSpotlight(r, 8, VIEWPORT)).toMatchObject({
left: '2px',
top: '2px'
})
})
it('clamps the far edges to the viewport inset', () => {
const r = new DOMRect(990, 100, 50, 40)
const { left, width } = clampSpotlight(r, 8, VIEWPORT)
expect(left).toBe('982px')
expect(width).toBe('16px')
})
it('never produces a negative size for an off-screen target', () => {
const r = new DOMRect(2000, 100, 50, 40)
expect(clampSpotlight(r, 8, VIEWPORT)).toMatchObject({ width: '0px' })
})
})
describe('topSafeInset', () => {
afterEach(() => {
document.documentElement.style.removeProperty('--comfy-topbar-height')
})
it('converts a rem top bar height to px and adds the card gap', () => {
document.documentElement.style.setProperty('--comfy-topbar-height', '3rem')
expect(topSafeInset()).toBe(48 + CARD_GAP)
})
it('reads a px top bar height directly and adds the card gap', () => {
document.documentElement.style.setProperty('--comfy-topbar-height', '50px')
expect(topSafeInset()).toBe(50 + CARD_GAP)
})
it('falls back to the card gap alone when the token is unset', () => {
expect(topSafeInset()).toBe(CARD_GAP)
})
})
describe('noTargetCardLeft', () => {
it('centers the card on a wide viewport', () => {
expect(noTargetCardLeft(1000)).toBe(350)
})
it('clamps to the viewport margin when the viewport is narrower than the card', () => {
expect(noTargetCardLeft(200)).toBe(12)
})
})

View File

@@ -0,0 +1,55 @@
export interface Viewport {
width: number
height: number
}
export interface BoxStyle {
left: string
top: string
width: string
height: string
}
const SPOTLIGHT_EDGE_INSET = 2
export const CARD_WIDTH = 300
export const VIEWPORT_MARGIN = 12
export const CARD_GAP = 16
// Kept tight so the spotlight glow doesn't spill onto an adjacent clickable control.
export const SPOTLIGHT_PAD = 4
export function clampSpotlight(
r: DOMRect,
pad: number,
viewport: Viewport
): BoxStyle {
const left = Math.max(SPOTLIGHT_EDGE_INSET, r.left - pad)
const top = Math.max(SPOTLIGHT_EDGE_INSET, r.top - pad)
const right = Math.min(viewport.width - SPOTLIGHT_EDGE_INSET, r.right + pad)
const bottom = Math.min(
viewport.height - SPOTLIGHT_EDGE_INSET,
r.bottom + pad
)
return {
left: `${left}px`,
top: `${top}px`,
width: `${Math.max(0, right - left)}px`,
height: `${Math.max(0, bottom - top)}px`
}
}
export function noTargetCardLeft(viewportWidth: number): number {
return Math.max(VIEWPORT_MARGIN, (viewportWidth - CARD_WIDTH) / 2)
}
const TOP_BAR_HEIGHT_VAR = '--comfy-topbar-height'
/** The top bar's height, read from the theme token, plus the standard gap. */
export function topSafeInset(): number {
const root = document.documentElement
const raw = getComputedStyle(root).getPropertyValue(TOP_BAR_HEIGHT_VAR).trim()
const px = raw.endsWith('rem')
? parseFloat(raw) * parseFloat(getComputedStyle(root).fontSize)
: parseFloat(raw)
return (Number.isFinite(px) ? px : 0) + CARD_GAP
}

View File

@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import {
clearCoachmarks,
coachmarkElements,
registerCoachmark,
targetMounted,
unregisterCoachmark,
waitForTarget
} from './coachmarkRegistry'
/** An element with a non-zero measured rect, so it counts as laid out. */
function laidOut(): HTMLElement {
const el = document.createElement('div')
el.getBoundingClientRect = () => new DOMRect(0, 0, 80, 30)
return el
}
describe('coachmarkRegistry', () => {
const a = document.createElement('div')
const b = document.createElement('div')
afterEach(clearCoachmarks)
it('resolves every element registered for an id', () => {
registerCoachmark('app-run-button', a)
registerCoachmark('app-run-button', b)
expect(coachmarkElements('app-run-button')).toEqual([a, b])
})
it('keeps the remaining elements when one of several unregisters', () => {
registerCoachmark('app-run-button', a)
registerCoachmark('app-run-button', b)
unregisterCoachmark('app-run-button', a)
expect(coachmarkElements('app-run-button')).toEqual([b])
})
})
describe('targetMounted', () => {
afterEach(clearCoachmarks)
it('is true once a laid-out element is registered', () => {
expect(targetMounted('app-run-button')).toBe(false)
registerCoachmark('app-run-button', laidOut())
expect(targetMounted('app-run-button')).toBe(true)
})
it('ignores a registered target that is not laid out (e.g. hidden)', () => {
registerCoachmark('outputs', document.createElement('div'))
expect(targetMounted('outputs')).toBe(false)
})
})
describe('waitForTarget', () => {
let frames: Array<() => void>
beforeEach(() => {
frames = []
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
frames.push(() => cb(0))
return frames.length
})
vi.stubGlobal('cancelAnimationFrame', () => {})
})
afterEach(() => {
clearCoachmarks()
vi.useRealTimers()
vi.unstubAllGlobals()
})
// Drain the poll queue; an unresolved poll reschedules, so cap the drain to
// avoid spinning when the target never lays out.
function runFrames(max = 50) {
let ran = 0
while (frames.length && ran < max) {
frames.shift()!()
ran++
}
}
it('resolves true immediately when a laid-out target is already mounted', async () => {
registerCoachmark('app-run-button', laidOut())
const signal = new AbortController().signal
await expect(waitForTarget('app-run-button', signal, 1000)).resolves.toBe(
true
)
})
it('resolves true once the target lays out before the timeout', async () => {
const signal = new AbortController().signal
const found = waitForTarget('app-run-button', signal, 1000)
registerCoachmark('app-run-button', laidOut())
runFrames()
await expect(found).resolves.toBe(true)
})
it('keeps waiting for a registered target until it lays out', async () => {
const el = document.createElement('div')
registerCoachmark('outputs', el)
const signal = new AbortController().signal
let resolved: boolean | undefined
void waitForTarget('outputs', signal, 1000).then((v) => (resolved = v))
runFrames()
await Promise.resolve()
expect(resolved).toBeUndefined()
el.getBoundingClientRect = () => new DOMRect(0, 0, 80, 30)
runFrames()
await Promise.resolve()
expect(resolved).toBe(true)
})
it('does not schedule rAF polling while no candidate is registered', () => {
const signal = new AbortController().signal
void waitForTarget('outputs', signal, 1000)
expect(frames).toHaveLength(0)
})
it('parks the poll when the last candidate unregisters and resumes on re-registration', async () => {
const el = document.createElement('div')
const signal = new AbortController().signal
let resolved: boolean | undefined
void waitForTarget('outputs', signal, 1000).then((v) => (resolved = v))
registerCoachmark('outputs', el)
await nextTick()
expect(frames.length).toBeGreaterThan(0)
unregisterCoachmark('outputs', el)
await nextTick()
runFrames()
expect(frames).toHaveLength(0)
registerCoachmark('outputs', laidOut())
await nextTick()
await Promise.resolve()
expect(resolved).toBe(true)
})
it('resolves false when the target never mounts (transient failure)', async () => {
vi.useFakeTimers()
const signal = new AbortController().signal
const found = waitForTarget('outputs', signal, 1000)
await vi.advanceTimersByTimeAsync(1000)
await expect(found).resolves.toBe(false)
})
it('resolves false when aborted before the target mounts', async () => {
const controller = new AbortController()
const found = waitForTarget('outputs', controller.signal, 10000)
controller.abort()
await expect(found).resolves.toBe(false)
})
})

View File

@@ -0,0 +1,84 @@
import { shallowReactive, watch } from 'vue'
import type { CoachId } from './onboardingTours'
const EMPTY: readonly HTMLElement[] = []
/** Laid out — a registered target that is currently visible and has a size. */
export function isLaidOut(el: HTMLElement): boolean {
const r = el.getBoundingClientRect()
return r.width > 0 && r.height > 0
}
// An id can map to several elements (e.g. responsive variants); consumers pick
// the first laid-out one.
const registry = shallowReactive(new Map<CoachId, readonly HTMLElement[]>())
export function registerCoachmark(id: CoachId, el: HTMLElement) {
registry.set(id, [...(registry.get(id) ?? EMPTY), el])
}
export function unregisterCoachmark(id: CoachId, el: HTMLElement) {
const next = (registry.get(id) ?? EMPTY).filter((entry) => entry !== el)
if (next.length) registry.set(id, next)
else registry.delete(id)
}
export function coachmarkElements(id: CoachId): readonly HTMLElement[] {
return registry.get(id) ?? EMPTY
}
export function targetMounted(id: CoachId): boolean {
return coachmarkElements(id).some(isLaidOut)
}
/** Resolves once a laid-out element for the id exists; false on timeout or abort. */
export function waitForTarget(
id: CoachId,
signal: AbortSignal,
timeoutMs: number
): Promise<boolean> {
if (targetMounted(id)) return Promise.resolve(true)
// An already-aborted signal never fires 'abort', so resolve up front.
if (signal.aborted) return Promise.resolve(false)
return new Promise((resolve) => {
let done = false
let frame = 0
function finish(found: boolean) {
if (done) return
done = true
stopWatch()
cancelAnimationFrame(frame)
clearTimeout(timer)
signal.removeEventListener('abort', onAbort)
resolve(found)
}
function onAbort() {
finish(false)
}
// Laid-out-ness is a layout read the registry can't observe, so it needs
// polling — but only while a candidate exists. Registration is reactive,
// so the watch (re)starts the poll instead of spinning every frame while
// the target hasn't even mounted.
function poll() {
if (targetMounted(id)) finish(true)
else if (coachmarkElements(id).length) frame = requestAnimationFrame(poll)
}
const stopWatch = watch(
() => coachmarkElements(id).length,
() => {
cancelAnimationFrame(frame)
poll()
},
{ flush: 'post' }
)
const timer = setTimeout(() => finish(false), timeoutMs)
signal.addEventListener('abort', onAbort)
poll()
})
}
/** Resets shared state between tests. */
export function clearCoachmarks() {
registry.clear()
}

View File

@@ -0,0 +1,516 @@
import type { DetachedWindowAPI } from 'happy-dom'
import { createPinia, disposePinia, setActivePinia } from 'pinia'
import type { Pinia } from 'pinia'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import type { Ref } from 'vue'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import type { AppMode } from '@/utils/appMode'
import { clearCoachmarks, registerCoachmark } from './coachmarkRegistry'
import { TOUR_SEEN_SETTING } from './onboardingTours'
import type { CoachId } from './onboardingTours'
import { useOnboardingTourStore } from './onboardingTourStore'
const settings = vi.hoisted(() => ({ store: new Map<string, unknown>() }))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: (key: string) =>
settings.store.get(key) ?? (key === TOUR_SEEN_SETTING ? [] : undefined),
set: (key: string, value: unknown) => {
settings.store.set(key, value)
return Promise.resolve()
}
})
}))
const telemetry = vi.hoisted(() => ({ track: vi.fn() }))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackOnboardingTour: telemetry.track })
}))
const appModeMock = vi.hoisted(
() =>
({ mode: null, hasOutputs: null }) as {
mode: Ref<AppMode> | null
hasOutputs: Ref<boolean> | null
}
)
vi.mock('@/composables/useAppMode', async () => {
const { ref: r } = await import('vue')
appModeMock.mode = r<AppMode>('graph')
return { useAppMode: () => ({ mode: appModeMock.mode }) }
})
vi.mock('@/stores/appModeStore', async () => {
const { ref: r } = await import('vue')
appModeMock.hasOutputs = r(false)
const hasOutputs = appModeMock.hasOutputs
return {
useAppModeStore: () => ({
get hasOutputs() {
return hasOutputs.value
}
})
}
})
const APP_MODE_TARGETS: CoachId[] = [
'inputs-list',
'app-run-button',
'outputs',
'assets-panel'
]
function seenTours(): string[] {
return (settings.store.get(TOUR_SEEN_SETTING) as string[] | undefined) ?? []
}
function setViewport(viewport: { width: number; height: number }) {
const happyDOM = (window as unknown as { happyDOM?: DetachedWindowAPI })
.happyDOM
if (!happyDOM) {
throw new Error('window.happyDOM is unavailable to set viewport')
}
happyDOM.setViewport(viewport)
}
let pinia: Pinia | undefined
function mountStore() {
pinia = createPinia()
setActivePinia(pinia)
return useOnboardingTourStore()
}
function startedCount() {
return telemetry.track.mock.calls.filter(([stage]) => stage === 'started')
.length
}
describe('onboardingTourStore', () => {
// Removed in teardown even when a test throws before its own cleanup.
const appendedTargets: HTMLElement[] = []
afterEach(() => {
if (pinia) disposePinia(pinia)
pinia = undefined
clearCoachmarks()
appendedTargets.forEach((el) => el.remove())
appendedTargets.length = 0
settings.store.clear()
setViewport({ width: 1024, height: 768 })
if (appModeMock.mode) appModeMock.mode.value = 'graph'
if (appModeMock.hasOutputs) appModeMock.hasOutputs.value = false
telemetry.track.mockClear()
vi.useRealTimers()
})
/** Register one laid-out element for a coach id, so its step resolves at once. */
function mountTarget(id: CoachId): HTMLElement {
const el = document.createElement('div')
el.getBoundingClientRect = () => new DOMRect(0, 0, 100, 100)
document.body.appendChild(el)
appendedTargets.push(el)
registerCoachmark(id, el)
return el
}
function registerAppModeTargets(
ids: CoachId[] = APP_MODE_TARGETS
): Map<CoachId, HTMLElement> {
return new Map(ids.map((id) => [id, mountTarget(id)]))
}
function enterApp(mode: AppMode, hasOutputs: boolean) {
const modeRef = appModeMock.mode
const outputsRef = appModeMock.hasOutputs
if (!modeRef || !outputsRef)
throw new Error('app mode mock not initialised')
modeRef.value = mode
outputsRef.value = hasOutputs
}
it('auto-opens when entering a populated app it has not seen', async () => {
mountStore()
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(1)
})
it('auto-opens when instantiated in an already-populated app', async () => {
enterApp('app', true)
mountStore()
await nextTick()
expect(startedCount()).toBe(1)
})
it('does not auto-open in an empty app with no linear controls', async () => {
mountStore()
enterApp('app', false)
await nextTick()
expect(startedCount()).toBe(0)
})
it('does not auto-open in arrange (builder) mode', async () => {
mountStore()
enterApp('builder:arrange', true)
await nextTick()
expect(startedCount()).toBe(0)
})
it('does not auto-open on a mobile-width viewport', async () => {
setViewport({ width: 500, height: 800 })
enterApp('app', true)
mountStore()
await nextTick()
expect(startedCount()).toBe(0)
})
it('does not auto-open a populated app once the tour has been dismissed', async () => {
settings.store.set(TOUR_SEEN_SETTING, ['appMode'])
mountStore()
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(0)
})
it('replays a seen tour when explicitly requested', async () => {
settings.store.set(TOUR_SEEN_SETTING, ['appMode'])
const store = mountStore()
store.replayTour('appMode')
await nextTick()
expect(startedCount()).toBe(1)
})
it('dismisses a replayed tour without the seen-flag when the user leaves its mode', async () => {
settings.store.set(TOUR_SEEN_SETTING, ['appMode'])
const store = mountStore()
enterApp('app', false)
await nextTick()
store.replayTour('appMode')
await nextTick()
expect(store.step?.name).toBe('landing')
enterApp('graph', false)
await nextTick()
expect(store.step).toBeNull()
const skipped = telemetry.track.mock.calls.find(
([stage]) => stage === 'skipped'
)
expect(skipped?.[1]).toMatchObject({ skip_reason: 'trigger_lost' })
})
it('keeps the tour running when outputs disappear but the mode still holds', async () => {
registerAppModeTargets()
const store = mountStore()
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(1)
enterApp('app', false)
await nextTick()
expect(store.step).not.toBeNull()
})
it('marks the tour seen when it ends normally', async () => {
const store = mountStore()
store.replayTour('appMode')
await nextTick()
store.skip()
expect(seenTours()).toContain('appMode')
// A deliberate dismissal reports the user as the skip reason.
const skipped = telemetry.track.mock.calls.find(
([stage]) => stage === 'skipped'
)
expect(skipped?.[1]).toMatchObject({ skip_reason: 'user' })
})
it('skips without the seen-flag and toasts when a deferred target never appears', async () => {
vi.useFakeTimers()
const store = mountStore()
store.replayTour('appMode')
await vi.advanceTimersByTimeAsync(0)
store.next()
expect(store.waitingForTarget).toBe(true)
// The deferred target (inputs list) is never registered; exhaust the wait.
await vi.advanceTimersByTimeAsync(8000)
expect(seenTours()).not.toContain('appMode')
// The skipped event reports the timed-out step, not the prior landing,
// and attributes the skip to the timeout rather than the user.
const skipped = telemetry.track.mock.calls.find(
([stage]) => stage === 'skipped'
)
expect(skipped?.[1]).toMatchObject({
step_number: 1,
coach_id: 'inputs-list',
skip_reason: 'target_timeout'
})
expect(useToastStore().messagesToAdd).toContainEqual(
expect.objectContaining({
severity: 'error',
detail: 'Something went wrong showing this tour'
})
)
})
it('advances once a deferred target mounts during the wait', async () => {
const store = mountStore()
store.replayTour('appMode')
await nextTick()
store.next()
expect(store.waitingForTarget).toBe(true)
mountTarget('inputs-list')
await nextTick()
expect(store.waitingForTarget).toBe(false)
expect(store.step?.coachId).toBe('inputs-list')
})
it('keeps the original deadline when advance is requested again mid-wait', async () => {
vi.useFakeTimers()
const store = mountStore()
store.replayTour('appMode')
await vi.advanceTimersByTimeAsync(0)
store.next()
expect(store.waitingForTarget).toBe(true)
await vi.advanceTimersByTimeAsync(5000)
store.next()
await vi.advanceTimersByTimeAsync(3000)
expect(useToastStore().messagesToAdd).toHaveLength(1)
})
it('does not toast or double-report when the user skips during a deferred wait', async () => {
vi.useFakeTimers()
const store = mountStore()
store.replayTour('appMode')
await vi.advanceTimersByTimeAsync(0)
store.next()
expect(store.waitingForTarget).toBe(true)
store.skip()
await vi.advanceTimersByTimeAsync(8000)
const skipped = telemetry.track.mock.calls.filter(
([stage]) => stage === 'skipped'
)
expect(skipped).toHaveLength(1)
expect(skipped[0]?.[1]).toMatchObject({ skip_reason: 'user' })
expect(useToastStore().messagesToAdd).toHaveLength(0)
})
it('ends an active tour without the seen-flag when its trigger stops holding', async () => {
registerAppModeTargets()
const store = mountStore()
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(1)
enterApp('graph', true)
await nextTick()
expect(store.step).toBeNull()
expect(seenTours()).not.toContain('appMode')
const skipped = telemetry.track.mock.calls.find(
([stage]) => stage === 'skipped'
)
expect(skipped?.[1]).toMatchObject({ skip_reason: 'trigger_lost' })
enterApp('app', true)
await nextTick()
expect(startedCount()).toBe(2)
})
it('aborts a pending deferred wait without a toast when the trigger stops holding', async () => {
vi.useFakeTimers()
const store = mountStore()
enterApp('app', true)
await vi.advanceTimersByTimeAsync(0)
store.next()
expect(store.waitingForTarget).toBe(true)
enterApp('graph', true)
await vi.advanceTimersByTimeAsync(8000)
expect(useToastStore().messagesToAdd).toHaveLength(0)
const skipReasons = telemetry.track.mock.calls
.filter(([stage]) => stage === 'skipped')
.map(([, meta]) => meta.skip_reason)
expect(skipReasons).toEqual(['trigger_lost'])
})
it('ignores a second concurrent request while the first tour is resolving', async () => {
const store = mountStore()
store.replayTour('appMode')
store.replayTour('appMode')
await nextTick()
expect(startedCount()).toBe(1)
})
it('advances through every step to completion and marks the tour seen', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
// Capped so a stuck tour fails instead of hanging.
for (let i = 0; i < 12 && !seenTours().includes('appMode'); i++) {
store.next()
await nextTick()
}
expect(seenTours()).toContain('appMode')
const completed = telemetry.track.mock.calls.find(
([stage]) => stage === 'completed'
)
expect(completed).toBeTruthy()
expect(completed?.[1]).not.toHaveProperty('skip_reason')
})
it('opens the assets sidebar tab and resolves its deferred panel on the assets step', async () => {
registerAppModeTargets(
APP_MODE_TARGETS.filter((id) => id !== 'assets-panel')
)
const store = mountStore()
store.replayTour('appMode')
await nextTick()
expect(store.countedStepsTotal).toBe(4)
// Advance landing -> inputs -> run -> outputs, then onto the assets step.
for (let i = 0; i < 4; i++) {
store.next()
await nextTick()
}
// The assets step defers on its panel; the tab auto-opens while it waits.
expect(store.waitingForTarget).toBe(true)
expect(useSidebarTabStore().activeSidebarTabId).toBe('assets')
mountTarget('assets-panel')
await nextTick()
expect(store.waitingForTarget).toBe(false)
expect(store.step?.coachId).toBe('assets-panel')
})
it('leaves an already-open assets tab open when reaching the assets step', async () => {
registerAppModeTargets()
const store = mountStore()
const sidebar = useSidebarTabStore()
sidebar.toggleSidebarTab('assets')
expect(sidebar.activeSidebarTabId).toBe('assets')
store.replayTour('appMode')
await nextTick()
for (let i = 0; i < 4; i++) {
store.next()
await nextTick()
}
expect(store.step?.coachId).toBe('assets-panel')
expect(sidebar.activeSidebarTabId).toBe('assets')
})
it('steps back to the previous step', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
store.next()
await nextTick()
store.next()
await nextTick()
expect(store.step?.coachId).toBe('app-run-button')
expect(store.canGoBack).toBe(true)
store.back()
await nextTick()
expect(store.step?.coachId).toBe('inputs-list')
})
it('reports step index 0 while no tour is active', () => {
const store = mountStore()
expect(store.countedStepIdx).toBe(0)
})
it('labels the buttons from the step, falling back to Next then Done', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
// The landing's `primary` entry overrides only the primary label.
expect(store.step?.landing).toBe(true)
expect(store.primaryLabel).toBe('Start tutorial')
expect(store.skipLabel).toBe('Skip')
expect(store.countedStepsTotal).toBe(4)
store.next()
await nextTick()
expect(store.primaryLabel).toBe('Next')
expect(store.skipLabel).toBe('Skip')
for (let i = 0; i < 3; i++) {
store.next()
await nextTick()
}
expect(store.isLast).toBe(true)
expect(store.primaryLabel).toBe('Done')
})
it('reports the user-visible step numbering, omitting it for the landing', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
// The landing isn't numbered, so the four spotlight steps carry the count.
const started = telemetry.track.mock.calls.find(
([stage]) => stage === 'started'
)
expect(started?.[1]).toEqual({ tour: 'appMode', step_count: 4 })
const landingShown = telemetry.track.mock.calls.find(
([stage]) => stage === 'step_shown'
)
expect(landingShown?.[1]).toEqual({ tour: 'appMode', step_count: 4 })
store.next()
await nextTick()
const shown = telemetry.track.mock.calls
.filter(([stage]) => stage === 'step_shown')
.at(-1)
expect(shown?.[1]).toEqual({
tour: 'appMode',
step_count: 4,
step_number: 1,
coach_id: 'inputs-list'
})
})
it('advancing off the landing does not mark the tour skipped', async () => {
registerAppModeTargets()
const store = mountStore()
store.replayTour('appMode')
await nextTick()
expect(store.step?.landing).toBe(true)
store.next()
await nextTick()
expect(store.step?.landing).toBeFalsy()
const skipped = telemetry.track.mock.calls.some(
([stage]) => stage === 'skipped'
)
expect(skipped).toBe(false)
})
})

View File

@@ -0,0 +1,238 @@
import { defineStore } from 'pinia'
import { computed, readonly, ref, shallowRef, watch } from 'vue'
import { t, te } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type {
OnboardingTourSkipReason,
OnboardingTourStage
} from '@/platform/telemetry/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { targetMounted, waitForTarget } from './coachmarkRegistry'
import { TOURS, TOUR_SEEN_SETTING, resolveSteps } from './onboardingTours'
import type { CoachStep, EntryPath } from './onboardingTours'
import { useTourTriggers } from './useTourTriggers'
const DEFER_TIMEOUT_MS = 8000
/**
* The tour state machine: which tour starts and when, which steps run, and the
* advance/skip/complete lifecycle.
*/
export const useOnboardingTourStore = defineStore('onboardingTour', () => {
const settingStore = useSettingStore()
const telemetry = useTelemetry()
const steps = shallowRef<CoachStep[]>([])
const stepIdx = ref(0)
const waitingForTarget = ref(false)
const activeTour = ref<EntryPath | null>(null)
let stepController: AbortController | null = null
const step = computed<CoachStep | null>(
() => steps.value[stepIdx.value] ?? null
)
const isLast = computed(() => stepIdx.value === steps.value.length - 1)
const countedSteps = computed(() => steps.value.filter((s) => !s.landing))
const countedStepsTotal = computed(() => countedSteps.value.length)
const countedStepIdx = computed(() => {
const s = step.value
return s ? countedSteps.value.indexOf(s) : 0
})
// Back navigates the numbered steps only — never into the landing.
const canGoBack = computed(() => countedStepIdx.value > 0)
function trackTour(
stage: OnboardingTourStage,
skipReason?: OnboardingTourSkipReason
) {
const tour = activeTour.value
// Definition-driven tours (firstRun) aren't in TOURS and report their own telemetry.
if (!tour || !TOURS[tour]) return
telemetry?.trackOnboardingTour(stage, {
tour,
step_count: countedSteps.value.length,
...(stage !== 'started' &&
countedStepIdx.value >= 0 && {
step_number: countedStepIdx.value + 1,
coach_id: step.value?.coachId
}),
...(skipReason && { skip_reason: skipReason })
})
}
function stepKey(suffix: string) {
return `onboardingCoachmarks.${activeTour.value}.${step.value?.name}.${suffix}`
}
const title = computed(() => (step.value ? t(stepKey('title')) : ''))
const body = computed(() => (step.value ? t(stepKey('body')) : ''))
// A step overrides the generic button labels by declaring `primary`/`skip`
// entries under its translation keys.
const primaryLabel = computed(() => {
if (step.value && te(stepKey('primary'))) return t(stepKey('primary'))
return isLast.value
? t('onboardingCoachmarks.done')
: t('onboardingCoachmarks.next')
})
const skipLabel = computed(() =>
step.value && te(stepKey('skip'))
? t(stepKey('skip'))
: t('onboardingCoachmarks.skip')
)
const backLabel = computed(() => t('onboardingCoachmarks.back'))
async function showStep(idx: number) {
const nextStep = steps.value[idx]
if (!nextStep) return
stepController?.abort()
const controller = new AbortController()
stepController = controller
const { signal } = controller
waitingForTarget.value = false
if (nextStep.openSidebarTab) openSidebarTab(nextStep.openSidebarTab)
if (
nextStep.deferTarget &&
nextStep.coachId &&
!targetMounted(nextStep.coachId)
) {
waitingForTarget.value = true
const found = await waitForTarget(
nextStep.coachId,
signal,
DEFER_TIMEOUT_MS
)
if (signal.aborted) {
if (stepController === controller) waitingForTarget.value = false
return
}
waitingForTarget.value = false
// Point at the timed-out step so telemetry reports it, and skip without
// the seen-flag so a missed target isn't permanent.
if (!found) {
stepIdx.value = idx
finish('skipped', { markSeen: false, skipReason: 'target_timeout' })
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('onboardingCoachmarks.loadError')
})
return
}
}
stepIdx.value = idx
trackTour('step_shown')
}
function openSidebarTab(tabId: string) {
const sidebar = useSidebarTabStore()
if (sidebar.activeSidebarTabId !== tabId) sidebar.toggleSidebarTab(tabId)
}
function next() {
if (waitingForTarget.value) return
if (isLast.value) {
finish('completed')
return
}
void showStep(stepIdx.value + 1)
}
function back() {
if (canGoBack.value) void showStep(stepIdx.value - 1)
}
function skip() {
finish('skipped')
}
function finish(
outcome: 'completed' | 'skipped',
{
markSeen = true,
skipReason = 'user'
}: { markSeen?: boolean; skipReason?: OnboardingTourSkipReason } = {}
) {
trackTour(outcome, outcome === 'skipped' ? skipReason : undefined)
stepController?.abort()
waitingForTarget.value = false
steps.value = []
stepIdx.value = 0
if (markSeen && activeTour.value) markTourSeen(activeTour.value)
activeTour.value = null
}
for (const [entryPath, trigger] of useTourTriggers()) {
watch(
trigger.autoOpen,
(visible) => {
if (visible) startTour(entryPath)
},
{ immediate: true }
)
watch(trigger.holds, (holding) => {
if (!holding && activeTour.value === entryPath)
finish('skipped', { markSeen: false, skipReason: 'trigger_lost' })
})
}
function hasSeenTour(entryPath: EntryPath): boolean {
return settingStore.get(TOUR_SEEN_SETTING).includes(entryPath)
}
function markTourSeen(entryPath: EntryPath) {
const seen = settingStore.get(TOUR_SEEN_SETTING)
if (seen.includes(entryPath)) return
void settingStore.set(TOUR_SEEN_SETTING, [...seen, entryPath])
}
function startTour(
entryPath: EntryPath,
{
force = false,
definition
}: { force?: boolean; definition?: CoachStep[] } = {}
) {
if (steps.value.length) return
if (!force && hasSeenTour(entryPath)) return
const def = definition ?? TOURS[entryPath]
if (!def) return
const resolved = resolveSteps(def, targetMounted)
if (!resolved.length) return
steps.value = resolved
activeTour.value = entryPath
trackTour('started')
void showStep(0)
}
function replayTour(entryPath: EntryPath) {
startTour(entryPath, { force: true })
}
return {
step,
isLast,
canGoBack,
title,
body,
primaryLabel,
skipLabel,
backLabel,
countedStepIdx,
countedStepsTotal,
waitingForTarget,
activeTour: readonly(activeTour),
startTour,
replayTour,
next,
back,
skip
}
})

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { resolveSteps } from './onboardingTours'
import type { CoachStep } from './onboardingTours'
function step(overrides: Partial<CoachStep> = {}): CoachStep {
return {
name: 'step',
placement: 'center',
...overrides
}
}
describe('resolveSteps', () => {
const isMounted = (mounted: boolean) => () => mounted
it('keeps a targetless step', () => {
const steps = [step()]
expect(resolveSteps(steps, isMounted(false))).toEqual(steps)
})
it('drops a step whose target is not mounted', () => {
const steps = [step({ coachId: 'app-run-button' })]
expect(resolveSteps(steps, isMounted(false))).toEqual([])
})
it('keeps a mounted step', () => {
const steps = [step({ coachId: 'app-run-button' })]
expect(resolveSteps(steps, isMounted(true))).toEqual(steps)
})
it('keeps a deferred step even before its target mounts', () => {
const steps = [step({ coachId: 'app-run-button', deferTarget: true })]
expect(resolveSteps(steps, isMounted(false))).toEqual(steps)
})
})

View File

@@ -0,0 +1,92 @@
export type EntryPath = 'appMode' | 'firstRun'
/** Setting holding the tours the user has completed or dismissed. */
export const TOUR_SEEN_SETTING = 'Comfy.OnboardingCoachmarks.Seen'
export type CoachPlacement =
| 'left'
| 'right'
| 'center'
| 'bottom'
/** Left of the target, vertically centred on it (clamps to the viewport edge). */
| 'leftCenter'
/** Sits on whichever horizontal side of the target has more room. */
| 'auto'
/** Every element a tour can point at; the e2e drift guard asserts each resolves. */
export const COACH_IDS = {
appRunButton: 'app-run-button',
inputsList: 'inputs-list',
outputs: 'outputs',
assetsPanel: 'assets-panel'
} as const
export type CoachId = (typeof COACH_IDS)[keyof typeof COACH_IDS]
export interface CoachStep {
/**
* Derives the step's translation keys:
* `onboardingCoachmarks.<tour>.<name>.title|body`, plus optional
* `primary`/`skip` button-label overrides.
*/
name: string
/** Element to spotlight (the first laid-out registered candidate wins). */
coachId?: CoachId
placement: CoachPlacement
/** Open this sidebar tab when the step starts (e.g. to reveal its target). */
openSidebarTab?: string
/** Target mounts later (e.g. a dialog); wait for it instead of dropping the step. */
deferTarget?: boolean
/** Renders the landing dialog instead of a spotlight. */
landing?: boolean
image?: string
}
/**
* Fixes the running step set (and so the step count) at tour start: drops steps
* whose own target isn't mounted, keeping targetless and deferred steps.
*/
export function resolveSteps(
steps: CoachStep[],
isMounted: (id: CoachId) => boolean
): CoachStep[] {
return steps.filter(
(s) => !s.coachId || s.deferTarget || isMounted(s.coachId)
)
}
export const TOURS: Partial<Record<EntryPath, CoachStep[]>> = {
appMode: [
{
name: 'landing',
landing: true,
placement: 'center',
image: '/assets/images/app-mode-landing.png'
},
{
name: 'inputs',
coachId: COACH_IDS.inputsList,
placement: 'auto',
deferTarget: true
},
{
name: 'run',
coachId: COACH_IDS.appRunButton,
placement: 'auto',
deferTarget: true
},
{
name: 'outputs',
coachId: COACH_IDS.outputs,
placement: 'leftCenter',
deferTarget: true
},
{
name: 'assets',
coachId: COACH_IDS.assetsPanel,
placement: 'auto',
deferTarget: true,
openSidebarTab: 'assets'
}
]
}

View File

@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it } from 'vitest'
import { effectScope, ref } from 'vue'
import { clearCoachmarks, registerCoachmark } from './coachmarkRegistry'
import type { CoachId, CoachStep } from './onboardingTours'
import { useCoachmarkTarget } from './useCoachmarkTarget'
function step(coachId: CoachId): CoachStep {
return { name: 'step', placement: 'right', coachId }
}
function laidOut(): HTMLElement {
const el = document.createElement('div')
el.getBoundingClientRect = () => new DOMRect(10, 10, 80, 30)
return el
}
function hidden(): HTMLElement {
const el = document.createElement('div')
el.getBoundingClientRect = () => new DOMRect(0, 0, 0, 0)
return el
}
describe('useCoachmarkTarget', () => {
afterEach(clearCoachmarks)
function setup(coachId: CoachId) {
const scope = effectScope()
const stepRef = ref<CoachStep | null>(step(coachId))
const cardRef = ref<HTMLElement | null>(null)
const api = scope.run(() => useCoachmarkTarget(stepRef, cardRef))!
return { scope, api }
}
it('resolves the first laid-out candidate for the step target', () => {
const el = laidOut()
registerCoachmark('outputs', el)
const { scope, api } = setup('outputs')
expect(api.targetEl.value).toBe(el)
scope.stop()
})
it('skips a registered target that is not laid out', () => {
registerCoachmark('outputs', hidden())
const laid = laidOut()
registerCoachmark('outputs', laid)
const { scope, api } = setup('outputs')
expect(api.targetEl.value).toBe(laid)
scope.stop()
})
it('picks up a target that registers after the step starts', () => {
const { scope, api } = setup('outputs')
expect(api.targetEl.value).toBeNull()
const el = laidOut()
registerCoachmark('outputs', el)
expect(api.targetEl.value).toBe(el)
scope.stop()
})
})

View File

@@ -0,0 +1,136 @@
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/vue'
import type { Middleware, Placement, Rect } from '@floating-ui/vue'
import { useEventListener } from '@vueuse/core'
import { computed, ref, toValue, watch, watchEffect } from 'vue'
import type { MaybeRefOrGetter, Ref } from 'vue'
import { CARD_GAP, VIEWPORT_MARGIN, topSafeInset } from './coachmarkLayout'
import { coachmarkElements, isLaidOut } from './coachmarkRegistry'
import type { CoachPlacement, CoachStep } from './onboardingTours'
// A target animating in via CSS transform reports through neither scroll nor
// resize events; poll each frame until its rect holds still this long.
const MOTION_SETTLE_MS = 250
const PLACEMENT: Record<
Exclude<CoachPlacement, 'auto' | 'center'>,
Placement
> = {
left: 'left-start',
right: 'right-start',
leftCenter: 'left',
bottom: 'bottom'
}
function floatingPlacement(step: CoachStep | null): Placement {
const placement = step?.placement
if (!placement || placement === 'auto' || placement === 'center')
return 'right-start'
return PLACEMENT[placement]
}
// Surfaces the measured target rect so the spotlight can trace it.
const captureReference: Middleware = {
name: 'captureReference',
fn: (state) => ({ data: { rect: state.rects.reference } })
}
function middleware(step: CoachStep | null, topInset: number): Middleware[] {
const list: Middleware[] = [offset(CARD_GAP)]
if (!step?.placement || step.placement === 'auto') list.push(flip())
// shift only guards the main axis by default; crossAxis keeps vertically-
// centred placements (leftCenter) on-screen too.
list.push(
shift({
crossAxis: true,
padding: {
top: topInset,
left: VIEWPORT_MARGIN,
right: VIEWPORT_MARGIN,
bottom: CARD_GAP
}
}),
captureReference
)
return list
}
/**
* Locates a step's target in the registry and positions the card beside it with
* Floating UI, following the target until its rect settles.
*/
export function useCoachmarkTarget(
step: MaybeRefOrGetter<CoachStep | null>,
cardRef: Ref<HTMLElement | null>
) {
const candidateEls = computed<readonly HTMLElement[]>(() => {
const id = toValue(step)?.coachId
return id ? coachmarkElements(id) : []
})
const targetEl = computed<HTMLElement | null>(
() => candidateEls.value.find(isLaidOut) ?? null
)
// The top bar's height only changes on resize, so read it once and refresh
// then — Floating UI re-runs the middleware every frame while tracking motion.
const topInset = ref(topSafeInset())
useEventListener('resize', () => {
topInset.value = topSafeInset()
})
const { floatingStyles, middlewareData, isPositioned, update } = useFloating(
targetEl,
cardRef,
{
strategy: 'fixed',
transform: false,
placement: () => floatingPlacement(toValue(step)),
middleware: () => middleware(toValue(step), topInset.value)
}
)
const targetRect = computed<DOMRect | null>(() => {
const data = middlewareData.value.captureReference as
| { rect: Rect }
| undefined
const rect = data?.rect
if (!rect || rect.width === 0) return null
return new DOMRect(rect.x, rect.y, rect.width, rect.height)
})
const trackMotion = ref(false)
const rectKey = computed(() => {
const r = targetRect.value
return r ? `${r.x},${r.y},${r.width},${r.height}` : ''
})
watch(
() => toValue(step),
(s) => {
trackMotion.value = !!s?.deferTarget
},
{ immediate: true }
)
watch(rectKey, (_key, _prev, onCleanup) => {
if (!trackMotion.value) return
const timer = setTimeout(() => {
trackMotion.value = false
}, MOTION_SETTLE_MS)
onCleanup(() => clearTimeout(timer))
})
watchEffect((onCleanup) => {
const reference = targetEl.value
const floating = cardRef.value
if (!reference || !floating) return
onCleanup(
autoUpdate(reference, floating, update, {
animationFrame: trackMotion.value
})
)
})
return { targetEl, targetRect, floatingStyles, isPositioned }
}

View File

@@ -0,0 +1,30 @@
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useDesktopLayout } from '@/composables/useDesktopLayout'
import { useAppModeStore } from '@/stores/appModeStore'
import type { EntryPath } from './onboardingTours'
export interface TourTrigger {
holds: ComputedRef<boolean>
autoOpen: ComputedRef<boolean>
}
/** Each tour's auto-open condition, paired with its entry path. */
export function useTourTriggers(): [EntryPath, TourTrigger][] {
const { mode } = useAppMode()
const appModeStore = useAppModeStore()
const desktopLayout = useDesktopLayout()
const inAppMode = computed(() => desktopLayout.value && mode.value === 'app')
return [
[
'appMode',
{
holds: inAppMode,
autoOpen: computed(() => inAppMode.value && appModeStore.hasOutputs)
}
]
]
}

View File

@@ -0,0 +1,73 @@
import { cleanup, render } from '@testing-library/vue'
import { afterEach, describe, expect, it } from 'vitest'
import { defineComponent, h, nextTick, ref, withDirectives } from 'vue'
import { coachmarkElements } from './coachmarkRegistry'
import type { CoachId } from './onboardingTours'
import { vCoachmark } from './vCoachmark'
// Mounts a host element carrying `v-coachmark`, so the directive runs through
// Vue's real mount/update/unmount lifecycle rather than hand-called hooks.
function mountHost(initial: CoachId | null) {
const coachId = ref<CoachId | null>(initial)
const rev = ref(0)
const Host = defineComponent({
setup: () => () =>
withDirectives(
h('div', { 'data-testid': 'host', 'data-rev': rev.value }),
[[vCoachmark, coachId.value]]
)
})
return {
...render(Host),
coachId,
// Forces a re-render without touching the bound id, to exercise the
// no-op-update guard.
rerender: () => {
rev.value++
return nextTick()
}
}
}
describe('vCoachmark', () => {
afterEach(cleanup)
it('registers the element and mirrors the id to data-coach-id on mount', () => {
const { getByTestId } = mountHost('app-run-button')
const host = getByTestId('host')
expect(host.dataset.coachId).toBe('app-run-button')
expect(coachmarkElements('app-run-button')).toContain(host)
})
it('does not register an element bound to a falsy id', () => {
const { getByTestId } = mountHost(null)
expect(getByTestId('host').dataset.coachId).toBeUndefined()
expect(coachmarkElements('app-run-button')).toHaveLength(0)
})
it('moves the element to the new id when the binding changes', async () => {
const { getByTestId, coachId } = mountHost('app-run-button')
const host = getByTestId('host')
coachId.value = 'outputs'
await nextTick()
expect(host.dataset.coachId).toBe('outputs')
expect(coachmarkElements('app-run-button')).not.toContain(host)
expect(coachmarkElements('outputs')).toContain(host)
})
it('ignores a re-render that leaves the id unchanged', async () => {
const { getByTestId, rerender } = mountHost('app-run-button')
const host = getByTestId('host')
await rerender()
expect(coachmarkElements('app-run-button')).toEqual([host])
})
it('unregisters and clears data-coach-id on unmount', () => {
const { getByTestId, unmount } = mountHost('app-run-button')
const host = getByTestId('host')
unmount()
expect(host.dataset.coachId).toBeUndefined()
expect(coachmarkElements('app-run-button')).not.toContain(host)
})
})

View File

@@ -0,0 +1,29 @@
import type { Directive } from 'vue'
import { registerCoachmark, unregisterCoachmark } from './coachmarkRegistry'
import type { CoachId } from './onboardingTours'
// The element's `data-coach-id` is the record of what it is registered as.
function sync(el: HTMLElement, id: CoachId | undefined | null) {
const prev = el.dataset.coachId as CoachId | undefined
if (prev === id) return
if (prev) {
unregisterCoachmark(prev, el)
delete el.dataset.coachId
}
if (id) {
el.dataset.coachId = id
registerCoachmark(id, el)
}
}
/**
* Marks an element as a coach-mark target: registers it in the reactive
* registry and mirrors the id to `data-coach-id` for e2e locators. A falsy
* value is a no-op, so it can be bound to a conditional id.
*/
export const vCoachmark: Directive<HTMLElement, CoachId | undefined | null> = {
mounted: (el, { value }) => sync(el, value),
updated: (el, { value }) => sync(el, value),
unmounted: (el) => sync(el, null)
}

View File

@@ -1,5 +1,5 @@
import {
cachedConsolidatedBillingEnabled,
cachedBillingControlEnabled,
cachedTeamWorkspacesEnabled,
remoteConfig,
remoteConfigState
@@ -60,8 +60,8 @@ export async function refreshRemoteConfig(
cachedTeamWorkspacesEnabled.value = Boolean(
config.team_workspaces_enabled
)
cachedConsolidatedBillingEnabled.value = Boolean(
config.consolidated_billing_enabled
cachedBillingControlEnabled.value = Boolean(
config.billing_control_enabled
)
}
return

View File

@@ -60,7 +60,7 @@ export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
undefined
)
export const cachedConsolidatedBillingEnabled = useStorage<boolean | undefined>(
'consolidated_billing_enabled' satisfies `${ServerFeatureFlag.CONSOLIDATED_BILLING_ENABLED}`,
export const cachedBillingControlEnabled = useStorage<boolean | undefined>(
'billing_control_enabled' satisfies `${ServerFeatureFlag.BILLING_CONTROL_ENABLED}`,
undefined
)

View File

@@ -110,12 +110,18 @@ export type RemoteConfig = {
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
free_tier_credits?: number
free_tier_balance?: {
allowance: number
used: number
remaining: number
}
new_free_tier_subscriptions?: boolean
workflow_sharing_enabled?: boolean
comfyhub_upload_enabled?: boolean
comfyhub_profile_gate_enabled?: boolean
unified_cloud_auth?: boolean
consolidated_billing_enabled?: boolean
billing_control_enabled?: boolean
onboarding_tour_enabled?: boolean
sentry_dsn?: string
turnstile_sitekey?: string
// Raw, unvalidated wire value (a server typo like 'enfroce' is possible).

View File

@@ -4,6 +4,7 @@ import {
SUPPORTED_LOCALE_OPTIONS
} from '@/locales/localeConfig'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { TOUR_SEEN_SETTING } from '@/platform/onboarding/onboardingTours'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { SettingParams } from '@/platform/settings/types'
import type { ColorPalettes } from '@/schemas/colorPaletteSchema'
@@ -977,6 +978,13 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
versionAdded: '1.8.7'
},
{
id: TOUR_SEEN_SETTING,
name: 'Onboarding coachmark tours the user has already seen',
type: 'hidden',
defaultValue: [],
versionAdded: '1.48.0'
},
{
id: 'Comfy.InstalledVersion',
name: 'The frontend version that was running when the user first installed ComfyUI',

View File

@@ -17,6 +17,8 @@ import type {
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OnboardingTourMetadata,
OnboardingTourStage,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
@@ -170,6 +172,13 @@ export class TelemetryRegistry implements TelemetryDispatcher {
this.dispatch((provider) => provider.trackSurvey?.(stage, responses))
}
trackOnboardingTour(
stage: OnboardingTourStage,
metadata: OnboardingTourMetadata
): void {
this.dispatch((provider) => provider.trackOnboardingTour?.(stage, metadata))
}
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
this.dispatch((provider) => provider.trackEmailVerification?.(stage))
}

View File

@@ -38,6 +38,8 @@ import type {
AuthMetadata,
DefaultViewSetMetadata,
EnterLinearMetadata,
OnboardingTourMetadata,
OnboardingTourStage,
RunButtonProperties,
ShareFlowMetadata,
ShellLayoutMetadata,
@@ -460,6 +462,42 @@ describe('MixpanelTelemetryProvider — direct event tracking methods', () => {
)
})
it.for<
[
OnboardingTourStage,
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
]
>([
['started', TelemetryEvents.ONBOARDING_TOUR_STARTED],
['step_shown', TelemetryEvents.ONBOARDING_TOUR_STEP_SHOWN],
['completed', TelemetryEvents.ONBOARDING_TOUR_COMPLETED],
['skipped', TelemetryEvents.ONBOARDING_TOUR_SKIPPED],
['run_triggered', TelemetryEvents.ONBOARDING_TOUR_RUN_TRIGGERED],
['upgrade_shown', TelemetryEvents.ONBOARDING_TOUR_UPGRADE_SHOWN],
['nudge_shown', TelemetryEvents.ONBOARDING_TOUR_NUDGE_SHOWN],
[
'explore_templates_clicked',
TelemetryEvents.ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED
]
])(
'trackOnboardingTour(%s) dispatches %s',
async ([stage, expectedEvent]) => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()
mockMixpanel.track.mockClear()
const metadata: OnboardingTourMetadata = {
tour: 'appMode',
step_count: 6,
step_number: 2,
coach_id: 'app-run-button'
}
provider.trackOnboardingTour(stage, metadata)
expect(mockMixpanel.track).toHaveBeenCalledWith(expectedEvent, metadata)
}
)
it('omits share_id from existing Mixpanel events', async () => {
const provider = new MixpanelTelemetryProvider()
await waitForMixpanelInit()

View File

@@ -20,6 +20,8 @@ import type {
HelpResourceClickedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OnboardingTourMetadata,
OnboardingTourStage,
PageVisibilityMetadata,
RunButtonProperties,
SettingChangedMetadata,
@@ -44,7 +46,7 @@ import type {
} from '../../types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import { TelemetryEvents } from '../../types'
import { OnboardingTourEvents, TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
@@ -280,6 +282,13 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
}
trackOnboardingTour(
stage: OnboardingTourStage,
metadata: OnboardingTourMetadata
): void {
this.trackEvent(OnboardingTourEvents[stage], metadata)
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses

View File

@@ -4,6 +4,7 @@ import type { Ref } from 'vue'
import { nextTick, ref } from 'vue'
import { TelemetryEvents } from '../../types'
import type { OnboardingTourStage } from '../../types'
const hoisted = vi.hoisted(() => {
const mockCapture = vi.fn()
@@ -718,6 +719,45 @@ describe('PostHogTelemetryProvider', () => {
shellLayoutMetadata
)
})
it.for<
[
OnboardingTourStage,
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
]
>([
['started', TelemetryEvents.ONBOARDING_TOUR_STARTED],
['step_shown', TelemetryEvents.ONBOARDING_TOUR_STEP_SHOWN],
['completed', TelemetryEvents.ONBOARDING_TOUR_COMPLETED],
['skipped', TelemetryEvents.ONBOARDING_TOUR_SKIPPED],
['run_triggered', TelemetryEvents.ONBOARDING_TOUR_RUN_TRIGGERED],
['upgrade_shown', TelemetryEvents.ONBOARDING_TOUR_UPGRADE_SHOWN],
['nudge_shown', TelemetryEvents.ONBOARDING_TOUR_NUDGE_SHOWN],
[
'explore_templates_clicked',
TelemetryEvents.ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED
]
])(
'maps onboarding tour stage %s to %s',
async ([stage, expectedEvent]) => {
const provider = createProvider()
await vi.dynamicImportSettled()
const metadata = {
tour: 'appMode',
step_count: 6,
step_number: 2,
coach_id: 'app-run-button'
} as const
provider.trackOnboardingTour(stage, metadata)
expect(hoisted.mockCapture).toHaveBeenCalledWith(
expectedEvent,
metadata
)
}
)
})
describe('survey tracking', () => {

View File

@@ -24,6 +24,8 @@ import type {
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OnboardingTourMetadata,
OnboardingTourStage,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
@@ -50,7 +52,11 @@ import type {
WorkflowSavedMetadata,
WorkspaceInviteMetadata
} from '../../types'
import { CANCELLATION_STAGE_EVENTS, TelemetryEvents } from '../../types'
import {
CANCELLATION_STAGE_EVENTS,
OnboardingTourEvents,
TelemetryEvents
} from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
const DEFAULT_DISABLED_EVENTS = [
@@ -421,6 +427,13 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, properties)
}
trackOnboardingTour(
stage: OnboardingTourStage,
metadata: OnboardingTourMetadata
): void {
this.trackEvent(OnboardingTourEvents[stage], metadata)
}
trackSurvey(
stage: 'opened' | 'submitted',
responses?: SurveyResponses

View File

@@ -33,6 +33,7 @@ export type PaymentIntentSource =
| 'invite_member_upsell'
| 'upload_model_upgrade'
| 'team_upgrade_resume'
| 'free_tier_quota'
export type SubscriptionCheckoutType = 'new' | 'change'
export type SubscriptionCheckoutTier = TierKey | 'team'
@@ -93,6 +94,57 @@ export interface SurveyResponses {
usage?: string
}
export type OnboardingTourStage =
| 'started'
| 'step_shown'
| 'completed'
| 'skipped'
| 'run_triggered'
| 'upgrade_shown'
| 'nudge_shown'
| 'explore_templates_clicked'
export type OnboardingTourSkipReason =
| 'user'
| 'target_timeout'
| 'trigger_lost'
/**
* `step_number` is 1-based and matches the "Step N of M" indicator the user
* sees, with `step_count` as M. Both `step_number` and `coach_id` are absent
* for steps with no numbered spotlight (e.g. the landing). `skip_reason` is
* present only on the `skipped` stage. `step_count` is absent on `nudge_shown`
* and `explore_templates_clicked`, which fire outside the step sequence.
*/
export interface OnboardingTourMetadata {
tour: string
step_count?: number
step_number?: number
coach_id?: string
skip_reason?: OnboardingTourSkipReason
}
/** `shape` labels the role-derived sequence, not the template — `'other'` is the
* honest bucket for graphs the resolver handles best-effort but that aren't a
* named shape. */
export type OnboardingTourShape = 't2i' | 'i2v' | 'image-edit' | 'other'
export type OnboardingTourEntry =
| 'getting_started'
| 'share_url'
| 'template_url'
export type OnboardingTourStepKey = 'upload' | 'prompt' | 'run' | 'result'
export type OnboardingTourRunStatus = 'success' | 'error' | 'interrupted'
/** Reported only by the first-run tour. No field carries user content or a
* share id, so no PII. */
export interface FirstRunTourMetadata extends OnboardingTourMetadata {
template_id?: string
shape?: OnboardingTourShape
entry?: OnboardingTourEntry
step_key?: OnboardingTourStepKey
status?: OnboardingTourRunStatus
}
export interface SurveyResponsesNormalized extends SurveyResponses {
industry_normalized?: string
industry_raw?: string
@@ -583,6 +635,12 @@ export interface TelemetryProvider {
// Survey flow events
trackSurvey?(stage: 'opened' | 'submitted', responses?: SurveyResponses): void
// Onboarding coachmark tour events
trackOnboardingTour?(
stage: OnboardingTourStage,
metadata: OnboardingTourMetadata
): void
// Email verification events
trackEmailVerification?(stage: 'opened' | 'requested' | 'completed'): void
@@ -689,6 +747,17 @@ export const TelemetryEvents = {
USER_SURVEY_OPENED: 'app:user_survey_opened',
USER_SURVEY_SUBMITTED: 'app:user_survey_submitted',
// Onboarding Tour
ONBOARDING_TOUR_STARTED: 'app:onboarding_tour_started',
ONBOARDING_TOUR_STEP_SHOWN: 'app:onboarding_tour_step_shown',
ONBOARDING_TOUR_COMPLETED: 'app:onboarding_tour_completed',
ONBOARDING_TOUR_SKIPPED: 'app:onboarding_tour_skipped',
ONBOARDING_TOUR_RUN_TRIGGERED: 'app:onboarding_tour_run_triggered',
ONBOARDING_TOUR_UPGRADE_SHOWN: 'app:onboarding_tour_upgrade_shown',
ONBOARDING_TOUR_NUDGE_SHOWN: 'app:onboarding_tour_nudge_shown',
ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED:
'app:onboarding_tour_explore_templates_clicked',
// Email Verification
USER_EMAIL_VERIFY_OPENED: 'app:user_email_verify_opened',
USER_EMAIL_VERIFY_REQUESTED: 'app:user_email_verify_requested',
@@ -752,6 +821,21 @@ export const TelemetryEvents = {
export type TelemetryEventName =
(typeof TelemetryEvents)[keyof typeof TelemetryEvents]
export const OnboardingTourEvents: Record<
OnboardingTourStage,
TelemetryEventName
> = {
started: TelemetryEvents.ONBOARDING_TOUR_STARTED,
step_shown: TelemetryEvents.ONBOARDING_TOUR_STEP_SHOWN,
completed: TelemetryEvents.ONBOARDING_TOUR_COMPLETED,
skipped: TelemetryEvents.ONBOARDING_TOUR_SKIPPED,
run_triggered: TelemetryEvents.ONBOARDING_TOUR_RUN_TRIGGERED,
upgrade_shown: TelemetryEvents.ONBOARDING_TOUR_UPGRADE_SHOWN,
nudge_shown: TelemetryEvents.ONBOARDING_TOUR_NUDGE_SHOWN,
explore_templates_clicked:
TelemetryEvents.ONBOARDING_TOUR_EXPLORE_TEMPLATES_CLICKED
}
export const CANCELLATION_STAGE_EVENTS = {
flow_opened: TelemetryEvents.SUBSCRIPTION_CANCEL_FLOW_OPENED,
confirmed: TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED,
@@ -772,6 +856,7 @@ export type ExecutionTriggerSource =
export type TelemetryEventProperties =
| AuthMetadata
| AuthErrorMetadata
| OnboardingTourMetadata
| SurveyResponses
| TemplateMetadata
| ExecutionContext

View File

@@ -5,6 +5,8 @@ import { createApp, defineComponent, nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { TemplateUrlLoadResult } from '@/platform/workflow/templates/composables/useTemplateUrlLoader'
import { useOnboardingEntryStore } from '../onboardingEntryStore'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
import { useWorkflowPersistenceV2 } from './useWorkflowPersistenceV2'
@@ -58,11 +60,17 @@ vi.mock('@/platform/workflow/core/services/workflowService', () => ({
})
}))
const templateLoaderMocks = vi.hoisted(() => ({
loadTemplateFromUrl: vi.fn(
async () => ({ loaded: false }) as TemplateUrlLoadResult
)
}))
vi.mock(
'@/platform/workflow/templates/composables/useTemplateUrlLoader',
() => ({
useTemplateUrlLoader: () => ({
loadTemplateFromUrl: vi.fn()
loadTemplateFromUrl: templateLoaderMocks.loadTemplateFromUrl
})
})
)
@@ -78,7 +86,8 @@ vi.mock('@/stores/commandStore', () => ({
}))
const routeMocks = vi.hoisted(() => ({
query: {} as Record<string, unknown>
query: {} as Record<string, unknown>,
replace: vi.fn()
}))
vi.mock('vue-router', () => ({
@@ -88,7 +97,7 @@ vi.mock('vue-router', () => ({
}
}),
useRouter: () => ({
replace: vi.fn()
replace: routeMocks.replace
})
}))
@@ -99,11 +108,12 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
const preservedQueryMocks = vi.hoisted(() => ({
payloads: {} as Record<string, Record<string, string> | undefined>
payloads: {} as Record<string, Record<string, string> | undefined>,
hydrate: vi.fn()
}))
vi.mock('@/platform/navigation/preservedQueryManager', () => ({
hydratePreservedQuery: vi.fn(),
hydratePreservedQuery: preservedQueryMocks.hydrate,
mergePreservedQueryIntoQuery: vi.fn(
(namespace: string, query: Record<string, unknown> = {}) => {
const payload = preservedQueryMocks.payloads[namespace]
@@ -125,7 +135,57 @@ vi.mock('@/platform/navigation/preservedQueryNamespaces', () => ({
}))
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
get isCloud() {
return onboardingMocks.isCloud
}
}))
const onboardingMocks = vi.hoisted(() => ({
isCloud: false,
onboardingTourEnabled: false,
isNewUser: null as boolean | null,
isSubscriptionEnabled: true,
isDesktop: true,
loadWorkflowTemplates: vi.fn(async () => {})
}))
vi.mock('@/composables/useDesktopLayout', () => ({
useDesktopLayout: () => ({
get value() {
return onboardingMocks.isDesktop
}
})
}))
vi.mock(
'@/platform/workflow/templates/repositories/workflowTemplatesStore',
() => ({
useWorkflowTemplatesStore: () => ({
loadWorkflowTemplates: onboardingMocks.loadWorkflowTemplates
})
})
)
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get onboardingTourEnabled() {
return onboardingMocks.onboardingTourEnabled
}
}
})
}))
vi.mock('@/services/useNewUserService', () => ({
useNewUserService: () => ({
isNewUser: () => onboardingMocks.isNewUser
})
}))
vi.mock('@/platform/cloud/subscription/composables/useSubscription', () => ({
useSubscription: () => ({
isSubscriptionEnabled: () => onboardingMocks.isSubscriptionEnabled
})
}))
vi.mock('../migration/migrateV1toV2', () => ({
@@ -206,6 +266,12 @@ describe('useWorkflowPersistenceV2', () => {
commandStoreMocks.execute.mockReset()
routeMocks.query = {}
preservedQueryMocks.payloads = {}
onboardingMocks.isCloud = false
onboardingMocks.onboardingTourEnabled = false
onboardingMocks.isNewUser = null
onboardingMocks.isSubscriptionEnabled = true
templateLoaderMocks.loadTemplateFromUrl.mockReset()
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({ loaded: false })
})
afterEach(() => {
@@ -617,5 +683,192 @@ describe('useWorkflowPersistenceV2', () => {
'Comfy.BrowseTemplates'
)
})
it('shows Getting Started instead of the templates browser for a flagged new user', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(loadBlankWorkflowMock).toHaveBeenCalled()
expect(entryStore.shouldShowGettingStarted).toBe(true)
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('opens the templates browser off the Cloud build, matching the tour gate', async () => {
onboardingMocks.isCloud = false
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('prefetches templates when Getting Started is shown so its cards are ready', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(onboardingMocks.loadWorkflowTemplates).toHaveBeenCalled()
})
it('opens the templates browser when the flag is on but the user is not new', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = false
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('opens the templates browser when the flag is off', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = false
onboardingMocks.isNewUser = true
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('opens the templates browser, not Getting Started, when subscriptions are disabled', async () => {
// The tour refuses when subscriptions are off, so the takeover must not show
// — otherwise it would dismiss into a bare canvas with no tour.
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
onboardingMocks.isSubscriptionEnabled = false
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
it('does not show Getting Started for a flagged new user arriving via a template URL', async () => {
onboardingMocks.isCloud = true
onboardingMocks.onboardingTourEnabled = true
onboardingMocks.isNewUser = true
routeMocks.query = { template: 'default-template-id' }
const entryStore = useOnboardingEntryStore()
const { initializeWorkflow } = mountWorkflowPersistence()
await initializeWorkflow()
expect(entryStore.shouldShowGettingStarted).toBe(false)
expect(commandStoreMocks.execute).not.toHaveBeenCalledWith(
'Comfy.BrowseTemplates'
)
})
})
describe('loadTemplateFromUrlIfPresent', () => {
it('surfaces the validated template id the loader reports', async () => {
routeMocks.query = { template: 'image_z_image_turbo' }
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
loaded: true,
templateId: 'image_z_image_turbo'
})
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
await expect(loadTemplateFromUrlIfPresent()).resolves.toEqual({
loaded: true,
templateId: 'image_z_image_turbo'
})
})
it('reports not-loaded when the loader loads nothing', async () => {
routeMocks.query = {}
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
loaded: false
})
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
await expect(loadTemplateFromUrlIfPresent()).resolves.toEqual({
loaded: false
})
})
it('restores a template preserved across login into the route query', async () => {
routeMocks.query = {}
preservedQueryMocks.payloads.template = {
template: 'image_z_image_turbo'
}
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
loaded: true,
templateId: 'image_z_image_turbo'
})
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
await loadTemplateFromUrlIfPresent()
expect(routeMocks.replace).toHaveBeenCalledWith({
query: { template: 'image_z_image_turbo' }
})
})
it('hydrates preserved template intent before delegating to the loader', async () => {
routeMocks.query = {}
preservedQueryMocks.payloads.template = {
template: 'image_z_image_turbo'
}
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
loaded: true,
templateId: 'image_z_image_turbo'
})
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
await loadTemplateFromUrlIfPresent()
const hydrateOrder =
preservedQueryMocks.hydrate.mock.invocationCallOrder[0]
const loadOrder =
templateLoaderMocks.loadTemplateFromUrl.mock.invocationCallOrder[0]
expect(hydrateOrder).toBeLessThan(loadOrder)
})
it('leaves the route untouched when there is no preserved intent', async () => {
routeMocks.query = { template: 'image_z_image_turbo' }
templateLoaderMocks.loadTemplateFromUrl.mockResolvedValue({
loaded: true,
templateId: 'image_z_image_turbo'
})
const { loadTemplateFromUrlIfPresent } = mountWorkflowPersistence()
await loadTemplateFromUrlIfPresent()
expect(routeMocks.replace).not.toHaveBeenCalled()
})
})
})

View File

@@ -16,11 +16,14 @@ import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useDesktopLayout } from '@/composables/useDesktopLayout'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
hydratePreservedQuery,
mergePreservedQueryIntoQuery
} from '@/platform/navigation/preservedQueryManager'
import { PRESERVED_QUERY_NAMESPACES } from '@/platform/navigation/preservedQueryNamespaces'
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
@@ -28,9 +31,17 @@ import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { useNewUserService } from '@/services/useNewUserService'
import { PERSIST_DEBOUNCE_MS } from '../base/draftTypes'
import { clearAllV2Storage } from '../base/storageIO'
import { migrateV1toV2 } from '../migration/migrateV1toV2'
import type { OnboardingCandidateDeps } from '../onboardingEntryStore'
import {
isOnboardingCandidate,
useOnboardingEntryStore
} from '../onboardingEntryStore'
import { useWorkflowDraftStoreV2 } from '../stores/workflowDraftStoreV2'
import { useWorkflowTabState } from './useWorkflowTabState'
import { useSharedWorkflowUrlLoader } from '@/platform/workflow/sharing/composables/useSharedWorkflowUrlLoader'
@@ -52,6 +63,14 @@ export function useWorkflowPersistenceV2() {
const draftStore = useWorkflowDraftStoreV2()
const tabState = useWorkflowTabState()
const toast = useToast()
const templatesStore = useWorkflowTemplatesStore()
const entryStore = useOnboardingEntryStore()
const onboardingDeps: OnboardingCandidateDeps = {
subscription: useSubscription(),
newUserService: useNewUserService(),
featureFlags: useFeatureFlags(),
desktop: useDesktopLayout()
}
const { onUserLogout } = useCurrentUser()
// Run migration on module load, passing clientId for tab state migration
@@ -179,7 +198,12 @@ export function useWorkflowPersistenceV2() {
await settingStore.set('Comfy.TutorialCompleted', true)
await useWorkflowService().loadBlankWorkflow()
if (!hasSharedWorkflowIntent() && !hasTemplateUrlIntent()) {
await useCommandStore().execute('Comfy.BrowseTemplates')
if (isOnboardingCandidate(onboardingDeps)) {
void templatesStore.loadWorkflowTemplates()
entryStore.showGettingStarted()
} else {
await useCommandStore().execute('Comfy.BrowseTemplates')
}
}
} else {
await comfyApp.loadGraphData()
@@ -223,12 +247,10 @@ export function useWorkflowPersistenceV2() {
}
const loadTemplateFromUrlIfPresent = async () => {
const query = await ensureTemplateQueryFromIntent()
const hasTemplateUrl = query.template && typeof query.template === 'string'
if (hasTemplateUrl) {
await templateUrlLoader.loadTemplateFromUrl()
}
// Hydrate any preserved ?template= intent into the route first; the loader
// reads the route and returns the id only after validating it.
await ensureTemplateQueryFromIntent()
return templateUrlLoader.loadTemplateFromUrl()
}
const loadSharedWorkflowFromUrlIfPresent = async () => {

View File

@@ -0,0 +1,57 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { ComputedRef } from 'vue'
import type { useFeatureFlags } from '@/composables/useFeatureFlags'
import type { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
import { isCloud } from '@/platform/distribution/types'
import type { useNewUserService } from '@/services/useNewUserService'
export interface OnboardingCandidateDeps {
subscription: ReturnType<typeof useSubscription>
newUserService: ReturnType<typeof useNewUserService>
featureFlags: ReturnType<typeof useFeatureFlags>
desktop: ComputedRef<boolean>
}
/**
* Shared by the Getting Started screen and the tour: the screen dismisses even
* when the tour then declines, so a looser gate on either side strands the user
* on a bare canvas. Deps are resolved by callers during setup — this runs after
* an await, where a first composable call would have no injection context.
*/
export function isOnboardingCandidate({
subscription,
newUserService,
featureFlags,
desktop
}: OnboardingCandidateDeps): boolean {
if (!desktop.value) return false
if (!isCloud) return false
if (!subscription.isSubscriptionEnabled()) return false
if (newUserService.isNewUser() !== true) return false
if (!featureFlags.flags.onboardingTourEnabled) return false
return true
}
/**
* Seam between the persistence layer and the onboarding Getting Started screen.
*
* `loadDefaultWorkflow` decides when a fresh user should land on Getting
* Started, but the screen lives in `renderer/` and cannot be imported from this
* layer. Both sides share this flag: persistence turns it on; the
* renderer-mounted overlay shows itself while set and clears it on exit.
*/
export const useOnboardingEntryStore = defineStore('onboardingEntry', () => {
const shouldShowGettingStarted = ref(false)
function showGettingStarted() {
shouldShowGettingStarted.value = true
}
function dismissGettingStarted() {
shouldShowGettingStarted.value = false
}
return { shouldShowGettingStarted, showGettingStarted, dismissGettingStarted }
})

View File

@@ -97,6 +97,51 @@ describe('useTemplateUrlLoader', () => {
expect(mockLoadWorkflowTemplate).not.toHaveBeenCalled()
})
it('returns the loaded template id when the template loads successfully', async () => {
mockQueryParams = { template: 'flux_simple' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({
loaded: true,
templateId: 'flux_simple'
})
})
it('returns not-loaded when no template param is present', async () => {
mockQueryParams = {}
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
})
it('returns not-loaded without a template id when the template fails to load', async () => {
mockQueryParams = { template: 'invalid-template' }
mockLoadWorkflowTemplate.mockResolvedValueOnce(false)
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
})
it('returns not-loaded when loading throws', async () => {
mockQueryParams = { template: 'flux_simple' }
mockLoadTemplates.mockRejectedValueOnce(new Error('Network error'))
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
})
it('returns not-loaded for an invalid template parameter', async () => {
mockQueryParams = { template: '../../../etc/passwd' }
const { loadTemplateFromUrl } = useTemplateUrlLoader()
await expect(loadTemplateFromUrl()).resolves.toEqual({ loaded: false })
})
it('loads template when query param is present', async () => {
mockQueryParams = { template: 'flux_simple' }

View File

@@ -10,6 +10,11 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useTemplateWorkflows } from './useTemplateWorkflows'
export interface TemplateUrlLoadResult {
loaded: boolean
templateId?: string
}
/**
* Composable for loading templates from URL query parameters
*
@@ -64,18 +69,18 @@ export function useTemplateUrlLoader() {
* Loads template from URL query parameters if present
* Handles errors internally and shows appropriate user feedback
*/
const loadTemplateFromUrl = async () => {
const loadTemplateFromUrl = async (): Promise<TemplateUrlLoadResult> => {
const templateParam = route.query.template
if (!templateParam || typeof templateParam !== 'string') {
return
return { loaded: false }
}
if (!isValidParameter(templateParam)) {
console.warn(
`[useTemplateUrlLoader] Invalid template parameter format: ${templateParam}`
)
return
return { loaded: false }
}
const sourceParam = (route.query.source as string | undefined) || 'default'
@@ -84,7 +89,7 @@ export function useTemplateUrlLoader() {
console.warn(
`[useTemplateUrlLoader] Invalid source parameter format: ${sourceParam}`
)
return
return { loaded: false }
}
const modeParam = route.query.mode as string | undefined
@@ -96,7 +101,7 @@ export function useTemplateUrlLoader() {
console.warn(
`[useTemplateUrlLoader] Invalid mode parameter format: ${modeParam}`
)
return
return { loaded: false }
}
if (modeParam && !isSupportedMode(modeParam)) {
@@ -121,11 +126,16 @@ export function useTemplateUrlLoader() {
templateName: templateParam
})
})
} else if (modeParam === 'linear') {
return { loaded: false }
}
if (modeParam === 'linear') {
// Set linear mode after successful template load
useTelemetry()?.trackEnterLinear({ source: 'template_url' })
canvasStore.linearMode = true
}
return { loaded: true, templateId: templateParam }
} catch (error) {
console.error(
'[useTemplateUrlLoader] Failed to load template from URL:',
@@ -136,6 +146,7 @@ export function useTemplateUrlLoader() {
summary: t('g.error'),
detail: t('g.errorLoadingTemplate')
})
return { loaded: false }
} finally {
cleanupUrlParams()
clearPreservedQuery(TEMPLATE_NAMESPACE)

View File

@@ -250,6 +250,9 @@ export type BillingStatus =
| 'pending_payment'
| 'paid'
| 'payment_failed'
// A Stripe-paused subscription stays `active` on the activity axis; the pause
// is a payment-lifecycle fact. Not emitted until cloud#5075 ships.
| 'paused'
| 'inactive'
export interface CurrentTeamCreditStop {

View File

@@ -0,0 +1,42 @@
<template>
<div
class="flex flex-col overflow-hidden rounded-2xl border border-border-default bg-base-background"
data-testid="insufficient-credits-member-message"
>
<div
class="flex h-12 items-center gap-2 border-b border-border-default p-4"
>
<p class="m-0 min-w-0 flex-1 font-inter text-sm text-base-foreground">
{{ $t('credits.insufficient.memberTitle') }}
</p>
<button
type="button"
:aria-label="$t('g.close')"
class="flex size-4 shrink-0 cursor-pointer items-center justify-center border-none bg-transparent text-base-foreground"
@click="onClose"
>
<i class="icon-[lucide--x] size-3" />
</button>
</div>
<div class="p-4">
<p class="m-0 font-inter text-sm text-muted-foreground">
{{ $t('credits.insufficient.memberDescription') }}
</p>
</div>
<div class="flex items-center justify-end p-4">
<Button variant="secondary" size="lg" @click="onClose">
{{ $t('credits.insufficient.memberCta') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { onClose } = defineProps<{
onClose: () => void
}>()
</script>

View File

@@ -0,0 +1,178 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { SubscriptionInfo } from '@/composables/billing/types'
import { i18n } from '@/i18n'
import type { BillingContextMockState } from '@/storybook/mocks/useBillingContext'
import { setBillingContextMock } from '@/storybook/mocks/useBillingContext'
import type { WorkspaceUIMockState } from '@/storybook/mocks/useWorkspaceUI'
import { setWorkspaceUIMock } from '@/storybook/mocks/useWorkspaceUI'
import BillingStatusBanner from './BillingStatusBanner.vue'
/**
* The single billing banner slot for team workspaces (FE-1246), rendered in
* priority order: paused > payment declined > out of credits > ending. At most
* one state shows at a time. Each story drives the real `deriveBillingBanner`
* through the stubbed billing context, so these are the states the backend can
* actually emit — not hand-set banner kinds.
*
* The amber triangle-alert marks action-needed states; the muted circle-alert is
* reserved for the informational "plan ends" notice (DES-380 severity rule).
*
* RUN WITH `DISTRIBUTION=cloud pnpm storybook`. The banner is cloud-only, and
* `isCloud` is compile-time, so under a plain `pnpm storybook` every story below
* renders empty.
*/
const meta: Meta<typeof BillingStatusBanner> = {
title: 'Platform/Workspace/BillingStatusBanner',
component: BillingStatusBanner,
parameters: { layout: 'fullscreen' }
}
export default meta
type Story = StoryObj<typeof BillingStatusBanner>
const RENEWAL_DATE = '2026-08-01T00:00:00Z'
const PLAN_END_DATE = '2026-08-01T00:00:00Z'
const teamSubscription: SubscriptionInfo = {
isActive: true,
tier: null,
duration: 'MONTHLY',
planSlug: 'team-monthly',
renewalDate: RENEWAL_DATE,
endDate: null,
isCancelled: false,
hasFunds: true
}
const funded = teamSubscription
const exhausted: SubscriptionInfo = { ...teamSubscription, hasFunds: false }
const cancelled: SubscriptionInfo = {
...teamSubscription,
isCancelled: true,
endDate: PLAN_END_DATE
}
const owner: Partial<WorkspaceUIMockState> = {}
const member: Partial<WorkspaceUIMockState> = {
canManageSubscription: false,
canManageSubscriptionLifecycle: false,
canTopUp: false
}
function story(
billing: Partial<BillingContextMockState>,
workspace: Partial<WorkspaceUIMockState>
): Story {
return {
beforeEach() {
// Dates in the copy go through vue-i18n's `d()`, so pin the locale rather
// than inherit the developer's.
i18n.global.locale.value = 'en'
setBillingContextMock({ isTeamPlan: true, ...billing })
setWorkspaceUIMock(workspace)
}
}
}
/**
* Stripe suspended the subscription. The backend folds billing_status into
* is_active, so a paused workspace always reports `is_active: false` — the
* pairing these stories pin.
*/
export const PausedOwner: Story = story(
{
subscription: funded,
isActiveSubscription: false,
billingStatus: 'paused',
subscriptionStatus: 'active'
},
owner
)
/** Members get an admin-directed notice and no action. */
export const PausedMember: Story = story(
{
subscription: funded,
isActiveSubscription: false,
billingStatus: 'paused',
subscriptionStatus: 'active'
},
member
)
/**
* A failed charge with Stripe still retrying. Owner-only, and `is_active: false`
* for the same reason paused is — payment_failed also denies spend.
*/
export const PaymentDeclined: Story = story(
{
subscription: funded,
isActiveSubscription: false,
billingStatus: 'payment_failed',
subscriptionStatus: 'active',
renewalDate: RENEWAL_DATE
},
owner
)
/** Payment declined before a renewal date is known. */
export const PaymentDeclinedNoDate: Story = story(
{
subscription: funded,
isActiveSubscription: false,
billingStatus: 'payment_failed',
subscriptionStatus: 'active'
},
owner
)
/** Shared team credits exhausted. Session-dismissible. */
export const OutOfCreditsOwner: Story = story(
{
subscription: exhausted,
isActiveSubscription: true,
billingStatus: 'paid',
subscriptionStatus: 'active',
renewalDate: RENEWAL_DATE
},
owner
)
/** Members can't top up, so they get contact-admin copy and no Add credits. */
export const OutOfCreditsMember: Story = story(
{
subscription: exhausted,
isActiveSubscription: true,
billingStatus: 'paid',
subscriptionStatus: 'active',
renewalDate: RENEWAL_DATE
},
member
)
/** Cancelled but still active until the period end. Informational. */
export const EndingOwner: Story = story(
{
subscription: cancelled,
isActiveSubscription: true,
billingStatus: 'paid',
subscriptionStatus: 'canceled'
},
owner
)
/**
* Reactivate is lifecycle-gated to the original owner, so a non-original owner
* sees the notice read-only.
*/
export const EndingNonOriginalOwner: Story = story(
{
subscription: cancelled,
isActiveSubscription: true,
billingStatus: 'paid',
subscriptionStatus: 'canceled'
},
{ canManageSubscriptionLifecycle: false }
)

View File

@@ -0,0 +1,317 @@
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import { createI18n } from 'vue-i18n'
import type {
BillingStatus,
WorkspaceType
} from '@/platform/workspace/api/workspaceApi'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
interface Subscription {
hasFunds: boolean
isCancelled: boolean
endDate: string | null
}
const state = vi.hoisted(() => ({
billingControlEnabled: true,
isActiveSubscription: true,
isTeamPlan: true,
billingStatus: 'paid' as string | null,
subscription: {
hasFunds: true,
isCancelled: false,
endDate: null
} as Subscription | null,
renewalDate: null as string | null,
workspaceType: 'team' as string,
canManageSubscription: true,
canManageSubscriptionLifecycle: true,
canTopUp: true,
showTopUpCreditsDialog: vi.fn(),
manageSubscription: vi.fn(),
handleResubscribe: vi.fn()
}))
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get billingControlEnabled() {
return state.billingControlEnabled
}
}
})
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: computed(() => state.isActiveSubscription),
isTeamPlan: computed(() => state.isTeamPlan),
billingStatus: computed(() => state.billingStatus as BillingStatus | null),
subscription: computed(() => state.subscription),
renewalDate: computed(() => state.renewalDate),
manageSubscription: state.manageSubscription
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: computed(() => ({
canManageSubscription: state.canManageSubscription,
canManageSubscriptionLifecycle: state.canManageSubscriptionLifecycle,
canTopUp: state.canTopUp
})),
workspaceType: computed(() => state.workspaceType as WorkspaceType)
})
}))
vi.mock('@/platform/workspace/composables/useResubscribe', () => ({
useResubscribe: () => ({
isResubscribing: computed(() => false),
handleResubscribe: state.handleResubscribe
})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showTopUpCreditsDialog: state.showTopUpCreditsDialog
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
workspacePanel: {
billingStatus: {
warning: {
title: 'Payment declined',
body: "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
bodyNoDate:
"Your last payment didn't go through. Update payment to avoid a pause."
},
paused: {
title: 'Subscription paused',
body: "This workspace's subscription is paused. Update payment to resume.",
memberBody:
"This workspace's subscription is paused. Your workspace admins need to update the payment method."
},
outOfCredits: {
title: 'Out of credits',
body: 'Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.',
bodyNoDate:
'Your team has used all its credits. Add more credits to continue generating.',
memberBody:
'Your team has used all its credits. Your workspace admins need to add more credits to continue generating.',
addCredits: 'Add credits',
dismiss: 'Dismiss'
},
ending: {
title: 'Your team plan ends on {date}',
body: 'Members keep full access until then. Reactivate to keep your shared credits and seats.',
reactivate: 'Reactivate plan'
},
updatePayment: 'Update payment'
}
}
}
}
})
const globalOptions = {
plugins: [i18n],
stubs: {
Button: {
template:
'<button v-bind="$attrs" @click="$emit(\'click\')"><slot/></button>',
props: ['variant', 'size', 'loading'],
emits: ['click']
}
}
}
function renderBanner() {
return render(BillingStatusBanner, { global: globalOptions })
}
function exhausted() {
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
}
// The spend gate folds billing_status into is_active, so the backend never emits
// paused alongside an active subscription.
function pausedState() {
state.billingStatus = 'paused'
state.isActiveSubscription = false
}
function paymentFailedState() {
state.billingStatus = 'payment_failed'
state.isActiveSubscription = false
}
describe('BillingStatusBanner', () => {
beforeEach(() => {
state.billingControlEnabled = true
state.isActiveSubscription = true
state.isTeamPlan = true
state.billingStatus = 'paid'
state.subscription = { hasFunds: true, isCancelled: false, endDate: null }
state.renewalDate = null
state.workspaceType = 'team'
state.canManageSubscription = true
state.canManageSubscriptionLifecycle = true
state.canTopUp = true
vi.clearAllMocks()
})
it('renders nothing for a healthy funded team', () => {
renderBanner()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('renders nothing when billing control is rolled back, even out of credits', () => {
state.billingControlEnabled = false
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
renderBanner()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shows out-of-credits with an Add credits action for owners', async () => {
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent('Out of credits')
await userEvent.click(screen.getByRole('button', { name: 'Add credits' }))
expect(state.showTopUpCreditsDialog).toHaveBeenCalledTimes(1)
})
it('shows out-of-credits contact-admin copy without an Add credits action for members', () => {
state.subscription = { hasFunds: false, isCancelled: false, endDate: null }
state.canManageSubscription = false
state.canTopUp = false
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Your workspace admins need to add more credits'
)
expect(
screen.queryByRole('button', { name: 'Add credits' })
).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
})
it('dismisses the out-of-credits banner for the session', async () => {
exhausted()
renderBanner()
await userEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shares one dismiss across instances rather than tracking it per mount', async () => {
exhausted()
render(
{
components: { BillingStatusBanner },
template: '<div><BillingStatusBanner /><BillingStatusBanner /></div>'
},
{ global: globalOptions }
)
expect(screen.getAllByRole('status')).toHaveLength(2)
await userEvent.click(screen.getAllByRole('button', { name: 'Dismiss' })[0])
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('shows the paused banner with Update payment for owners', async () => {
pausedState()
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent('Subscription paused')
expect(screen.getByRole('status')).toHaveTextContent(
'Update payment to resume'
)
await userEvent.click(
screen.getByRole('button', { name: 'Update payment' })
)
expect(state.manageSubscription).toHaveBeenCalledTimes(1)
})
it('shows the paused member notice without an action', () => {
pausedState()
state.canManageSubscription = false
state.canTopUp = false
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Your workspace admins need to update the payment method'
)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('shows the payment-declined banner with Update payment for owners', () => {
paymentFailedState()
state.renewalDate = '2026-08-01T00:00:00Z'
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent('Payment declined')
expect(screen.getByRole('status')).toHaveTextContent(/will pause on \S+/)
expect(screen.getByRole('status')).not.toHaveTextContent('{date}')
expect(
screen.getByRole('button', { name: 'Update payment' })
).toBeInTheDocument()
})
it('falls back to the no-date payment-declined copy when there is no renewal date', () => {
paymentFailedState()
state.renewalDate = null
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Update payment to avoid a pause'
)
expect(screen.getByRole('status')).not.toHaveTextContent('will pause on')
expect(screen.getByRole('status')).not.toHaveTextContent('{date}')
})
it('shows the ending banner with a Reactivate action', async () => {
state.subscription = {
hasFunds: true,
isCancelled: true,
endDate: '2026-08-01T00:00:00Z'
}
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Your team plan ends on'
)
await userEvent.click(
screen.getByRole('button', { name: 'Reactivate plan' })
)
expect(state.handleResubscribe).toHaveBeenCalledTimes(1)
})
it('shows the ending banner read-only to a non-original owner (Reactivate is lifecycle-gated)', () => {
state.subscription = {
hasFunds: true,
isCancelled: true,
endDate: '2026-08-01T00:00:00Z'
}
state.canManageSubscriptionLifecycle = false
renderBanner()
expect(screen.getByRole('status')).toHaveTextContent(
'Your team plan ends on'
)
expect(
screen.queryByRole('button', { name: 'Reactivate plan' })
).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,165 @@
<template>
<div v-if="banner" class="@container">
<div
role="status"
class="flex flex-col gap-3 rounded-2xl border border-interface-stroke/60 bg-base-background p-4 @2xl:flex-row @2xl:items-center @2xl:gap-2"
>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<i
:class="
cn(
'size-4 shrink-0',
banner.muted
? 'icon-[lucide--circle-alert] text-muted-foreground'
: 'icon-[lucide--triangle-alert] text-warning-background'
)
"
/>
<span class="text-sm text-base-foreground">{{ banner.title }}</span>
</div>
<p class="m-0 pl-6 text-sm text-muted-foreground">{{ banner.body }}</p>
</div>
<div
v-if="banner.dismissible || banner.action"
class="flex shrink-0 flex-wrap items-center gap-2 pl-6 @2xl:pl-0"
>
<Button
v-if="banner.dismissible"
variant="textonly"
size="lg"
@click="dismiss"
>
{{ $t('workspacePanel.billingStatus.outOfCredits.dismiss') }}
</Button>
<Button
v-if="banner.action === 'addCredits'"
variant="secondary"
size="lg"
@click="handleAddCredits"
>
{{ $t('workspacePanel.billingStatus.outOfCredits.addCredits') }}
</Button>
<Button
v-else-if="banner.action === 'reactivate'"
variant="secondary"
size="lg"
:loading="isResubscribing"
@click="handleResubscribe"
>
{{ $t('workspacePanel.billingStatus.ending.reactivate') }}
</Button>
<Button
v-else-if="banner.action === 'updatePayment'"
variant="inverted"
size="lg"
@click="handleUpdatePayment"
>
{{ $t('workspacePanel.billingStatus.updatePayment') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useBillingBanner } from '@/platform/workspace/composables/useBillingBanner'
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useDialogService } from '@/services/dialogService'
type BannerAction = 'addCredits' | 'reactivate' | 'updatePayment'
const { t, d } = useI18n()
const { renewalDate, subscription, manageSubscription } = useBillingContext()
const { permissions } = useWorkspaceUI()
const { kind, dismiss } = useBillingBanner()
const { isResubscribing, handleResubscribe } = useResubscribe()
const dialogService = useDialogService()
const canManage = computed(() => permissions.value.canManageSubscription)
const canManageLifecycle = computed(
() => permissions.value.canManageSubscriptionLifecycle
)
const canTopUp = computed(() => permissions.value.canTopUp)
const cycleResetDate = computed(() => {
const raw = renewalDate.value
return raw ? d(new Date(raw), { month: 'short', day: 'numeric' }) : ''
})
const planEndDate = computed(() => {
const raw = subscription.value?.endDate
return raw
? d(new Date(raw), { year: 'numeric', month: 'long', day: 'numeric' })
: ''
})
interface BannerView {
muted: boolean
title: string
body: string
action: BannerAction | null
dismissible: boolean
}
const banner = computed<BannerView | null>(() => {
const bs = 'workspacePanel.billingStatus'
switch (kind.value) {
case 'paused':
return {
muted: false,
title: t(`${bs}.paused.title`),
body: canManage.value
? t(`${bs}.paused.body`)
: t(`${bs}.paused.memberBody`),
action: canManage.value ? 'updatePayment' : null,
dismissible: false
}
case 'paymentFailed':
return {
muted: false,
title: t(`${bs}.warning.title`),
body: cycleResetDate.value
? t(`${bs}.warning.body`, { date: cycleResetDate.value })
: t(`${bs}.warning.bodyNoDate`),
action: 'updatePayment',
dismissible: false
}
case 'outOfCredits':
return {
muted: false,
title: t(`${bs}.outOfCredits.title`),
body: canTopUp.value
? cycleResetDate.value
? t(`${bs}.outOfCredits.body`, { date: cycleResetDate.value })
: t(`${bs}.outOfCredits.bodyNoDate`)
: t(`${bs}.outOfCredits.memberBody`),
action: canTopUp.value ? 'addCredits' : null,
dismissible: true
}
case 'ending':
return {
muted: true,
title: t(`${bs}.ending.title`, { date: planEndDate.value }),
body: t(`${bs}.ending.body`),
action: canManageLifecycle.value ? 'reactivate' : null,
dismissible: false
}
default:
return null
}
})
function handleAddCredits() {
void dialogService.showTopUpCreditsDialog()
}
function handleUpdatePayment() {
void manageSubscription()
}
</script>

View File

@@ -65,6 +65,16 @@ vi.mock(
})
)
vi.mock(
'@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue',
() => ({
default: {
name: 'BillingStatusBanner',
template: '<div data-testid="billing-banner" />'
}
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -93,6 +103,25 @@ function renderComponent() {
})
}
describe('WorkspacePanelContent billing banner', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockWorkspaceType.value = 'team'
})
it('hosts a single banner slot above the tab content, so it shows on every tab', () => {
renderComponent()
const banner = screen.getByTestId('billing-banner')
const planPanel = screen.getByText('workspacePanel.tabs.planCredits')
expect(
planPanel.compareDocumentPosition(banner) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy()
})
})
describe('WorkspacePanelContent members tab label', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@@ -41,6 +41,8 @@
</TabsTrigger>
</TabsList>
<BillingStatusBanner class="mt-4" />
<TabsContent value="plan" class="mt-4">
<SubscriptionPanelContentWorkspace />
</TabsContent>
@@ -58,6 +60,7 @@ import { computed, onMounted, ref } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import type { BillingBannerInputs } from './useBillingBanner'
import { deriveBillingBanner } from './useBillingBanner'
const funded: BillingBannerInputs = {
billingControlEnabled: true,
isTeamPlan: true,
isLoaded: true,
isActiveSubscription: true,
billingStatus: 'paid',
hasFunds: true,
isCancelled: false,
endDate: null,
canManage: true,
outOfCreditsDismissed: false
}
// The backend folds billing_status into is_active, so every spend-denying status
// arrives paired with is_active=false. Pinning that pairing is what keeps these
// cases honest — spreading `funded` instead would assert an input the backend
// cannot emit, and pass no matter where the check sits.
const paused: Partial<BillingBannerInputs> = {
billingStatus: 'paused',
isActiveSubscription: false
}
const paymentFailed: Partial<BillingBannerInputs> = {
billingStatus: 'payment_failed',
isActiveSubscription: false
}
function derive(overrides: Partial<BillingBannerInputs>) {
return deriveBillingBanner({ ...funded, ...overrides })
}
describe('deriveBillingBanner', () => {
it('shows no banner for a healthy funded team', () => {
expect(derive({})).toBeNull()
})
it('shows no banner outside a team plan', () => {
expect(derive({ isTeamPlan: false, hasFunds: false })).toBeNull()
})
it('shows no banner when billing control is rolled back, even out of credits', () => {
expect(derive({ billingControlEnabled: false, hasFunds: false })).toBeNull()
})
it('shows no banner until the subscription snapshot has loaded', () => {
expect(derive({ isLoaded: false, hasFunds: false })).toBeNull()
})
it('surfaces out of credits when the balance is exhausted', () => {
expect(derive({ hasFunds: false })).toBe('outOfCredits')
})
it('shows out of credits to members too', () => {
expect(derive({ hasFunds: false, canManage: false })).toBe('outOfCredits')
})
it('hides out of credits once dismissed', () => {
expect(derive({ hasFunds: false, outOfCreditsDismissed: true })).toBeNull()
})
it('shows payment failed to owners even though the backend reports the plan inactive', () => {
expect(derive(paymentFailed)).toBe('paymentFailed')
})
it('prioritizes payment failure over out of credits for owners', () => {
expect(derive({ ...paymentFailed, hasFunds: false })).toBe('paymentFailed')
})
it('hides payment failed from members, who get the run-lock modal instead', () => {
expect(derive({ ...paymentFailed, canManage: false })).toBeNull()
})
it('prioritizes paused above everything, for owners and members', () => {
expect(derive({ ...paused, hasFunds: false })).toBe('paused')
expect(derive({ ...paused, canManage: false })).toBe('paused')
})
it('shows paused even though the backend reports the workspace inactive', () => {
expect(derive(paused)).toBe('paused')
})
it('surfaces the ending banner for a cancelled-but-active owner', () => {
expect(
derive({
isCancelled: true,
endDate: '2026-08-01T00:00:00Z'
})
).toBe('ending')
})
it('does not show the ending banner until the end date is populated', () => {
expect(
derive({
isCancelled: true,
endDate: null
})
).toBeNull()
})
it('hides the ending banner from members', () => {
expect(
derive({
isCancelled: true,
endDate: '2026-08-01T00:00:00Z',
canManage: false
})
).toBeNull()
})
it('shows no banner for an inactive subscription (that is a run-lock modal)', () => {
expect(
derive({ isActiveSubscription: false, billingStatus: 'inactive' })
).toBeNull()
})
})

View File

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
const mocks = vi.hoisted(() => ({
billing: null as {
isActiveSubscription: { value: boolean }
isTeamPlan: { value: boolean }
billingStatus: { value: string | null }
subscription: { value: { hasFunds: boolean } | null }
} | null,
billingControlEnabled: null as { value: boolean } | null
}))
vi.mock('@/platform/distribution/types', () => ({ isCloud: true }))
vi.mock('@/composables/useFeatureFlags', async () => {
const { ref } = await import('vue')
const billingControlEnabled = ref(true)
mocks.billingControlEnabled = billingControlEnabled
return {
useFeatureFlags: () => ({
flags: {
get billingControlEnabled() {
return billingControlEnabled.value
}
}
})
}
})
vi.mock('@/composables/billing/useBillingContext', async () => {
const { ref } = await import('vue')
const billing = {
isActiveSubscription: ref(true),
isTeamPlan: ref(true),
billingStatus: ref<string | null>('paid'),
subscription: ref<{ hasFunds: boolean } | null>({ hasFunds: true })
}
mocks.billing = billing
return { useBillingContext: () => billing }
})
vi.mock('@/platform/workspace/composables/useWorkspaceUI', async () => {
const { computed } = await import('vue')
return {
useWorkspaceUI: () => ({
permissions: computed(() => ({
canManageSubscription: true,
canManageSubscriptionLifecycle: true,
canTopUp: true
}))
})
}
})
import { useBillingBanner } from './useBillingBanner'
describe('useBillingBanner', () => {
beforeEach(() => {
const b = mocks.billing!
b.isActiveSubscription.value = true
b.isTeamPlan.value = true
b.billingStatus.value = 'paid'
b.subscription.value = { hasFunds: true }
mocks.billingControlEnabled!.value = true
})
it('suppresses the banner entirely when billing control is rolled back', async () => {
const b = mocks.billing!
const { kind } = useBillingBanner()
b.subscription.value = { hasFunds: false }
await nextTick()
expect(kind.value).toBe('outOfCredits')
mocks.billingControlEnabled!.value = false
await nextTick()
expect(kind.value).toBeNull()
})
it('re-shows the out-of-credits banner after a top-up and a later exhaustion', async () => {
const b = mocks.billing!
const { kind, dismiss } = useBillingBanner()
b.subscription.value = { hasFunds: false }
await nextTick()
expect(kind.value).toBe('outOfCredits')
dismiss()
await nextTick()
expect(kind.value).toBeNull()
b.subscription.value = { hasFunds: true }
await nextTick()
b.subscription.value = { hasFunds: false }
await nextTick()
expect(kind.value).toBe('outOfCredits')
})
})

View File

@@ -0,0 +1,106 @@
import { createSharedComposable } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { isCloud } from '@/platform/distribution/types'
import type { BillingStatus } from '@/platform/workspace/api/workspaceApi'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
export type BillingBannerKind =
| 'paused'
| 'paymentFailed'
| 'outOfCredits'
| 'ending'
export interface BillingBannerInputs {
billingControlEnabled: boolean
isTeamPlan: boolean
isLoaded: boolean
isActiveSubscription: boolean
billingStatus: BillingStatus | null
hasFunds: boolean | null
isCancelled: boolean
endDate: string | null
canManage: boolean
outOfCreditsDismissed: boolean
}
// The single billing banner slot, in priority order: paused > paymentFailed >
// outOfCredits > ending. billingControlEnabled is the FE-1246 kill switch: the
// whole banner is behind it so a PostHog rollback hides it for everyone. Then
// gated on the team PLAN rather than the workspace type, because personal
// workspaces are due to gain team plans (BE-1526) — a workspace-type gate would
// then hide the banner from real team subscribers.
export function deriveBillingBanner(
inputs: BillingBannerInputs
): BillingBannerKind | null {
if (!inputs.billingControlEnabled || !inputs.isTeamPlan || !inputs.isLoaded) {
return null
}
// Both sit above the isActiveSubscription gate because the backend folds
// billing_status into is_active: paused and payment_failed each report
// is_active=false, so either check would be dead code below it.
if (inputs.billingStatus === 'paused') return 'paused'
if (inputs.billingStatus === 'payment_failed' && inputs.canManage) {
return 'paymentFailed'
}
// Inactive workspaces surface a run-lock modal, not this banner. Members hit
// this on payment_failed, which is per design — only billing managers see it.
if (!inputs.isActiveSubscription) return null
if (inputs.hasFunds === false && !inputs.outOfCreditsDismissed) {
return 'outOfCredits'
}
if (inputs.isCancelled && inputs.endDate && inputs.canManage) {
return 'ending'
}
return null
}
function useBillingBannerInternal() {
const { isActiveSubscription, billingStatus, subscription, isTeamPlan } =
useBillingContext()
const { permissions } = useWorkspaceUI()
const { flags } = useFeatureFlags()
const dismissed = ref(false)
const kind = computed<BillingBannerKind | null>(() => {
if (!isCloud) return null
return deriveBillingBanner({
billingControlEnabled: flags.billingControlEnabled,
isTeamPlan: isTeamPlan.value,
isLoaded: subscription.value !== null,
isActiveSubscription: isActiveSubscription.value,
billingStatus: billingStatus.value,
hasFunds: subscription.value?.hasFunds ?? null,
isCancelled: subscription.value?.isCancelled ?? false,
endDate: subscription.value?.endDate ?? null,
canManage: permissions.value.canManageSubscription,
outOfCreditsDismissed: dismissed.value
})
})
// Dismiss silences only the out-of-credits banner, and only for the current
// exhaustion episode: reset once the workspace is funded again so a later
// exhaustion re-shows. Shared state, so it survives the settings panel
// unmounting when the dialog closes.
const hasExhaustedFunds = computed(
() => subscription.value?.hasFunds === false
)
watch(hasExhaustedFunds, (exhausted) => {
if (!exhausted) dismissed.value = false
})
function dismiss() {
dismissed.value = true
}
return { kind, dismiss }
}
export const useBillingBanner = createSharedComposable(useBillingBannerInternal)

View File

@@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import FirstRunTourNudge from './FirstRunTourNudge.vue'
import { useFirstRunTourStore } from './firstRunTourStore'
const SAMPLE_IMAGE =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="320" height="200"><rect width="100%" height="100%" fill="%234f46e5"/></svg>'
)
const meta: Meta<typeof FirstRunTourNudge> = {
title: 'Renderer/OnboardingTour/FirstRunTourNudge',
component: FirstRunTourNudge,
parameters: {
layout: 'fullscreen',
backgrounds: { default: 'dark' }
},
decorators: [
() => {
const store = useFirstRunTourStore()
store.resultMedia = { url: SAMPLE_IMAGE, kind: 'image' }
store.showNudge()
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { FirstRunTourNudge },
template: '<FirstRunTourNudge />'
})
}

View File

@@ -0,0 +1,277 @@
import { createTestingPinia } from '@pinia/testing'
import { fireEvent, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
const mocks = vi.hoisted(() => ({
show: vi.fn(),
trackOnboardingTour: vi.fn()
}))
// A reactive backing so the component's modal-close watch actually fires, the
// same way the real store's `isDialogOpen` reads a reactive dialog stack.
const openDialogs = ref<string[]>([])
vi.mock('@/composables/useWorkflowTemplateSelectorDialog', () => ({
useWorkflowTemplateSelectorDialog: () => ({ show: mocks.show })
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackOnboardingTour: mocks.trackOnboardingTour })
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
isDialogOpen: (key: string) => openDialogs.value.includes(key)
})
}))
vi.mock('@/scripts/app', () => ({ app: { canvas: null } }))
import FirstRunTourNudge from './FirstRunTourNudge.vue'
import { useFirstRunTourStore } from './firstRunTourStore'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderNudge() {
// No appear delay: these assert what the nudge shows, not when it fades in.
return render(FirstRunTourNudge, {
props: { appearDelayMs: 0 },
global: { plugins: [i18n] }
})
}
const nudgeTitle = enMessages.onboardingTour.nudge.title
describe('FirstRunTourNudge', () => {
let store: ReturnType<typeof useFirstRunTourStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useFirstRunTourStore()
mocks.show.mockReset()
mocks.trackOnboardingTour.mockReset()
openDialogs.value = []
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders nothing until the nudge is requested', () => {
renderNudge()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
it('shows the post-run prompt once the store requests it', async () => {
renderNudge()
store.showNudge()
expect(await screen.findByText(nudgeTitle)).toBeInTheDocument()
})
describe('appear delay', () => {
afterEach(() => {
vi.useRealTimers()
})
it('holds the nudge back so the fresh result gets a beat on its own', async () => {
vi.useFakeTimers()
render(FirstRunTourNudge, {
props: { appearDelayMs: 2000 },
global: { plugins: [i18n] }
})
store.showNudge()
await vi.advanceTimersByTimeAsync(1999)
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
await vi.advanceTimersByTimeAsync(1)
expect(screen.getByText(nudgeTitle)).toBeInTheDocument()
})
it('never appears when the nudge is withdrawn inside the delay', async () => {
// Dismissing during the wait must cancel the pending appearance, not fire late.
vi.useFakeTimers()
render(FirstRunTourNudge, {
props: { appearDelayMs: 2000 },
global: { plugins: [i18n] }
})
store.showNudge()
await vi.advanceTimersByTimeAsync(1000)
store.dismissNudge()
await vi.advanceTimersByTimeAsync(5000)
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
it('reports the nudge as shown only when it actually appears', async () => {
vi.useFakeTimers()
render(FirstRunTourNudge, {
props: { appearDelayMs: 2000 },
global: { plugins: [i18n] }
})
store.showNudge()
await vi.advanceTimersByTimeAsync(1000)
expect(mocks.trackOnboardingTour).not.toHaveBeenCalledWith(
'nudge_shown',
expect.anything()
)
await vi.advanceTimersByTimeAsync(1000)
expect(mocks.trackOnboardingTour).toHaveBeenCalledExactlyOnceWith(
'nudge_shown',
expect.objectContaining({ tour: 'firstRun' })
)
})
})
const FALLBACK_MEDIA = '/assets/images/og-image.png'
const mediaSources = () =>
screen
.getAllByRole('img', { name: nudgeTitle })
.map((img) => img.getAttribute('src'))
it('shows only the default image until a result has been captured', async () => {
renderNudge()
store.showNudge()
await screen.findByRole('status')
expect(mediaSources()).toEqual([FALLBACK_MEDIA])
})
it('keeps the default image until the generated image has decoded', async () => {
renderNudge()
store.resultMedia = { url: 'result.png', kind: 'image' }
store.showNudge()
await screen.findByRole('status')
expect(mediaSources()).toContain(FALLBACK_MEDIA)
await fireEvent.load(screen.getByTestId('onboarding-nudge-image'))
expect(mediaSources()).toEqual(['result.png'])
})
it('drops back to the default image when a decoded image later fails', async () => {
renderNudge()
store.resultMedia = { url: 'gone.png', kind: 'image' }
store.showNudge()
await screen.findByRole('status')
await fireEvent.load(screen.getByTestId('onboarding-nudge-image'))
expect(mediaSources()).toEqual(['gone.png'])
await fireEvent.error(screen.getByTestId('onboarding-nudge-image'))
expect(mediaSources()).toEqual([FALLBACK_MEDIA])
expect(screen.queryByTestId('onboarding-nudge-image')).toBeNull()
})
it('keeps the default image until the looping video has data', async () => {
renderNudge()
store.resultMedia = { url: 'result.mp4', kind: 'video' }
store.showNudge()
const video = await screen.findByTestId('onboarding-nudge-video')
expect(video).toHaveAttribute('src', 'result.mp4')
expect(mediaSources()).toEqual([FALLBACK_MEDIA])
await fireEvent(video, new Event('loadeddata'))
expect(screen.queryByRole('img')).not.toBeInTheDocument()
})
it('opens the template library and reports the click on Explore templates', async () => {
renderNudge()
store.showNudge()
const user = userEvent.setup()
await user.click(
await screen.findByRole('button', {
name: enMessages.onboardingTour.nudge.explore
})
)
expect(mocks.show).toHaveBeenCalledOnce()
expect(mocks.trackOnboardingTour).toHaveBeenCalledWith(
'explore_templates_clicked',
expect.objectContaining({ tour: 'firstRun' })
)
})
it('permanently dismisses on Not now and does not resurface', async () => {
renderNudge()
store.showNudge()
const user = userEvent.setup()
await user.click(
await screen.findByRole('button', {
name: enMessages.onboardingTour.nudge.dismiss
})
)
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
// A later trigger must not bring it back this session.
store.showNudge()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
it('defers past the open upgrade modal and surfaces once it closes', async () => {
openDialogs.value = ['free-tier-info']
// showNudge while the modal is open must defer, not overlap the paywall.
store.showNudge()
renderNudge()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
openDialogs.value = []
expect(await screen.findByText(nudgeTitle)).toBeInTheDocument()
})
it('stays hidden on modal close when the nudge was never requested', async () => {
openDialogs.value = ['subscription-required']
renderNudge()
openDialogs.value = []
await nextTick()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
it('surfaces the deferred nudge only once across repeated modal cycles', async () => {
openDialogs.value = ['free-tier-info']
store.showNudge()
renderNudge()
const user = userEvent.setup()
openDialogs.value = []
await user.click(
await screen.findByRole('button', {
name: enMessages.onboardingTour.nudge.dismiss
})
)
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
// A second upgrade-modal cycle must not resurface the dismissed nudge:
// the arm was consumed on the first surfacing and dismissal is permanent.
openDialogs.value = ['free-tier-info']
await nextTick()
openDialogs.value = []
await nextTick()
expect(screen.queryByText(nudgeTitle)).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,150 @@
<template>
<div
v-if="shown"
role="status"
class="pointer-events-auto fixed right-0 bottom-0 z-1000 flex w-80 animate-in flex-col overflow-hidden rounded-tl-xl border-t border-l border-border-default/50 bg-base-background shadow-lg duration-500 fade-in-0"
>
<div class="relative h-50 w-full bg-secondary-background">
<img
v-if="!mediaReady"
:src="FALLBACK_MEDIA"
:alt="t('onboardingTour.nudge.title')"
class="absolute inset-0 size-full object-cover"
/>
<video
v-if="!mediaFailed && media?.kind === 'video'"
:key="media.url"
:src="media.url"
data-testid="onboarding-nudge-video"
class="size-full object-cover"
autoplay
muted
loop
playsinline
@loadeddata="onMediaLoaded"
@error="onMediaFailed"
/>
<img
v-else-if="!mediaFailed && media"
:key="media.url"
:src="media.url"
:alt="t('onboardingTour.nudge.title')"
data-testid="onboarding-nudge-image"
class="size-full object-cover"
@load="onMediaLoaded"
@error="onMediaFailed"
/>
<Button
class="absolute top-2 right-2 size-8 opacity-50 hover:opacity-100"
variant="secondary"
size="icon"
:aria-label="t('g.close')"
@click="store.dismissNudge()"
>
<i class="icon-[lucide--x] size-4" aria-hidden="true" />
</Button>
</div>
<div
class="flex flex-col gap-2 border-t border-border-default px-4 pt-6 pb-4"
>
<p class="m-0 text-sm/5 font-bold text-base-foreground">
{{ t('onboardingTour.nudge.title') }}
</p>
<p class="m-0 text-sm text-muted-foreground">
{{ t('onboardingTour.nudge.body') }}
</p>
</div>
<div class="flex items-center justify-end gap-4 px-4 pb-4">
<Button
variant="link"
size="unset"
class="h-6 text-sm font-normal"
@click="store.dismissNudge()"
>
{{ t('onboardingTour.nudge.dismiss') }}
</Button>
<Button
variant="inverted"
size="lg"
class="font-normal"
@click="onExplore"
>
{{ t('onboardingTour.nudge.explore') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowTemplateSelectorDialog } from '@/composables/useWorkflowTemplateSelectorDialog'
import { trackFirstRunTour } from './firstRunTourTelemetry'
import { isUpgradeModalOpen, useFirstRunTourStore } from './firstRunTourStore'
const FALLBACK_MEDIA = '/assets/images/og-image.png'
/** Delayed, so the fresh result is seen before the nudge fades in over it. */
const { appearDelayMs = 1500 } = defineProps<{ appearDelayMs?: number }>()
const { t } = useI18n()
const store = useFirstRunTourStore()
const { resultMedia: media } = storeToRefs(store)
/** Holds the default image until the result has actually decoded. */
const mediaReady = ref(false)
const mediaFailed = ref(false)
watch(media, () => {
mediaReady.value = false
mediaFailed.value = false
})
function onMediaLoaded() {
mediaReady.value = true
}
function onMediaFailed() {
mediaReady.value = false
mediaFailed.value = true
}
const shouldShow = computed(() => store.shouldShowNudge)
const shown = ref(false)
const { start: startAppearDelay, stop: cancelAppearDelay } = useTimeoutFn(
() => {
shown.value = true
trackFirstRunTour('nudge_shown')
},
() => appearDelayMs,
{ immediate: false }
)
watch(shouldShow, (visible) => {
cancelAppearDelay()
shown.value = false
if (visible) startAppearDelay()
})
// The run-step gate arms the nudge behind the upgrade modal; surface it only once
// that modal closes, so the two never overlap.
const upgradeModalOpen = computed(() => isUpgradeModalOpen())
watch(upgradeModalOpen, (open, wasOpen) => {
if (wasOpen && !open && store.nudgeArmed) store.showNudge()
})
function onExplore() {
useWorkflowTemplateSelectorDialog().show('command')
trackFirstRunTour('explore_templates_clicked')
store.dismissNudge()
}
</script>

View File

@@ -0,0 +1,84 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { toNodeId } from '@/types/nodeId'
import FirstRunTourOverlay from './FirstRunTourOverlay.vue'
import { useFirstRunTourStore } from './firstRunTourStore'
import type { TourStep } from './tourSequence'
interface StoryArgs {
stepIndex: number
}
const SAMPLE_IMAGE =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160"><rect width="100%" height="100%" fill="%234f46e5"/></svg>'
)
const steps: TourStep[] = [
{ kind: 'upload', nodeId: toNodeId(1) },
{
kind: 'prompt',
nodeId: null,
prompt: {
subgraphNodeId: toNodeId(2),
innerNodeId: toNodeId(3),
widgetName: 'text',
portFallback: 'prompt'
}
},
{ kind: 'run', nodeId: null },
{ kind: 'result', nodeId: toNodeId(4), mediaKind: 'image' }
]
const meta: Meta<StoryArgs> = {
title: 'Renderer/OnboardingTour/FirstRunTourOverlay',
component: FirstRunTourOverlay,
parameters: {
layout: 'fullscreen',
backgrounds: { default: 'dark' }
},
argTypes: {
stepIndex: { control: { type: 'number', min: 0, max: 3 } }
},
decorators: [
(_, context) => {
const store = useFirstRunTourStore()
store.isActive = true
store.steps = steps
store.stepIndex = context.args.stepIndex
const activeStep = steps[context.args.stepIndex]
store.resultMedia =
activeStep?.kind === 'result'
? { url: SAMPLE_IMAGE, kind: activeStep.mediaKind ?? 'image' }
: null
// Spotlight geometry needs a live litegraph canvas (absent in Storybook),
// so holes are exercised by the unit test; this catalogs the coach-mark.
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
const render: Story['render'] = () => ({
components: { FirstRunTourOverlay },
template: '<FirstRunTourOverlay />'
})
export const FirstStep: Story = {
args: { stepIndex: 0 },
render
}
export const MultiReveal: Story = {
args: { stepIndex: 1 },
render
}
export const LastStep: Story = {
args: { stepIndex: 3 },
render
}

View File

@@ -0,0 +1,558 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { toNodeId } from '@/types/nodeId'
import type * as adapterModule from './canvasSpotlightAdapter'
import type { ScreenRect } from './canvasSpotlightAdapter'
import type { TourStep } from './tourSequence'
type AdapterModule = typeof adapterModule
const mocks = vi.hoisted(() => ({
maskRects: [] as ScreenRect[],
// Optional per-node-id rects: when set, `maskRectsFor` returns one rect per id
// that has an entry, so the revealed (holes) and spotlit (rings) calls resolve
// by which ids they carry — not by how many. Falls back to `maskRects`.
rectsById: null as Record<string, ScreenRect> | null,
focusNodes: vi.fn(),
// A camera already at rest: these assert what a step renders, not the settle
// machine (covered against the real `trackSettle` in the adapter's own tests).
transformKey: 'settled' as string | null,
viewport: { left: 0, top: 0, width: 1440, height: 900 } as ScreenRect,
controller: {
end: vi.fn(),
back: vi.fn(),
advance: vi.fn()
}
}))
// Only the litegraph-backed reads are stubbed; the pure geometry helpers stay real,
// so a test that pans a target off screen exercises the same code prod does.
vi.mock('./canvasSpotlightAdapter', async (importOriginal) => ({
...(await importOriginal<AdapterModule>()),
// Zero so a step's copy is gated only on the camera settling, never on a wait.
TOUR_FOCUS_DURATION_MS: 0,
// Return a fresh array (like prod) so a caller pushing to it can't grow the mock.
maskRectsFor: (ids: unknown[]) =>
mocks.rectsById === null
? [...mocks.maskRects]
: (ids as { toString(): string }[])
.map((id) => mocks.rectsById?.[String(id)])
.filter((r): r is ScreenRect => r !== undefined),
focusNodes: mocks.focusNodes,
canvasViewport: () => mocks.viewport,
canvasTransformKey: () => mocks.transformKey,
canvasElement: () => null
}))
vi.mock('./useFirstRunTourController', () => ({
useFirstRunTourController: () => mocks.controller
}))
vi.mock('@/scripts/app', () => ({ app: { canvas: null } }))
import FirstRunTourOverlay from './FirstRunTourOverlay.vue'
import { useFirstRunTourStore } from './firstRunTourStore'
import enMessages from '@/locales/en/main.json'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
const promptStep: TourStep = {
kind: 'prompt',
nodeId: null,
prompt: {
subgraphNodeId: toNodeId(2),
innerNodeId: toNodeId(3),
widgetName: 'text',
portFallback: 'prompt'
}
}
const runStep: TourStep = { kind: 'run', nodeId: null }
const videoResultStep: TourStep = {
kind: 'result',
nodeId: toNodeId(4),
mediaKind: 'video'
}
const imageResultStep: TourStep = {
kind: 'result',
nodeId: toNodeId(4),
mediaKind: 'image'
}
function rect(left: number): ScreenRect {
return { left, top: 0, width: 100, height: 50 }
}
function renderOverlay() {
return render(FirstRunTourOverlay, { global: { plugins: [i18n] } })
}
describe('FirstRunTourOverlay', () => {
let store: ReturnType<typeof useFirstRunTourStore>
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
store = useFirstRunTourStore()
mocks.maskRects = []
mocks.rectsById = null
mocks.transformKey = 'settled'
mocks.viewport = { left: 0, top: 0, width: 1440, height: 900 }
mocks.focusNodes.mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders nothing while the tour is idle', () => {
renderOverlay()
expect(screen.queryByRole('dialog')).toBeNull()
})
it('names the dialog for assistive technology when active', async () => {
store.isActive = true
store.steps = [promptStep, runStep]
renderOverlay()
expect(
await screen.findByRole('dialog', { name: 'Getting started tour' })
).toBeInTheDocument()
})
it('draws a focus ring for each spotlit node when active', async () => {
mocks.maskRects = [rect(0), rect(200)]
store.isActive = true
store.steps = [promptStep, runStep]
store.stepIndex = 0
renderOverlay()
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(2)
})
})
it('rings only the current step, not every revealed node', async () => {
// At the prompt step both the upload node (1) and the prompt host (2) are
// revealed, but only the prompt host is spotlit. Keying rects by id proves
// the ring set is the current step's targets, not the accumulated reveals.
const uploadStep: TourStep = { kind: 'upload', nodeId: toNodeId(1) }
mocks.rectsById = { '1': rect(0), '2': rect(200) }
store.isActive = true
store.steps = [uploadStep, promptStep, runStep]
store.stepIndex = 1
renderOverlay()
await vi.waitFor(() => {
// Holes cover both revealed nodes (1 & 2); rings cover only spotlit node 2.
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
})
it('shows the step counter derived from stepIndex and the step total', async () => {
store.isActive = true
store.steps = [promptStep, runStep, videoResultStep]
store.stepIndex = 1
renderOverlay()
expect(await screen.findByText('2 of 3')).toBeInTheDocument()
})
it.for([
{
name: 'renders the t2i prompt copy',
shape: 't2i',
body: 'Your image gets built from this description. Try anything you like.'
},
{
name: 'renders the i2v prompt copy',
shape: 'i2v',
body: 'The image sets the scene. This tells it what happens next.'
},
{
name: 'renders the image-edit prompt copy',
shape: 'image-edit',
body: 'Your image stays as it is. Describe what you want different.'
},
{
name: 'renders the other prompt copy',
shape: 'other',
body: 'This is your prompt. Change it to change your result.'
}
] as const)('$name', async ({ shape, body }) => {
store.isActive = true
store.steps = [{ ...promptStep, shape }, runStep]
renderOverlay()
expect(await screen.findByText(body)).toBeInTheDocument()
})
it.for([
{
name: 'renders the i2v upload copy',
shape: 'i2v',
body: 'This picture is your first frame. Swap in your own whenever you like.'
},
{
name: 'renders the image-edit upload copy',
shape: 'image-edit',
body: "This is the picture you'll edit. Swap in your own whenever you like."
},
{
name: 'renders the other upload copy',
shape: 'other',
body: 'This image feeds the workflow. Swap in your own whenever you like.'
}
] as const)('$name', async ({ shape, body }) => {
store.isActive = true
store.steps = [{ kind: 'upload', nodeId: toNodeId(1), shape }, runStep]
renderOverlay()
expect(await screen.findByText(body)).toBeInTheDocument()
})
it('renders video result copy for a video sink once the media is captured', async () => {
store.isActive = true
store.steps = [runStep, videoResultStep]
store.stepIndex = 1
store.resultMedia = { url: 'blob:video-output', kind: 'video' }
renderOverlay()
expect(
await screen.findByText(
'Your new video lands right here — ready to download or share.'
)
).toBeInTheDocument()
})
it('keeps result media out of the coach-mark (it lands on the canvas node)', async () => {
store.isActive = true
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
store.resultMedia = { url: 'blob:image-output', kind: 'image' }
renderOverlay()
await screen.findByText(
'Your new image lands right here — ready to download or share.'
)
expect(screen.queryByRole('img')).toBeNull()
expect(screen.queryByTestId('onboarding-result-video')).toBeNull()
})
it('ends the tour with skip when the Skip control is pressed', async () => {
store.isActive = true
store.steps = [promptStep, runStep]
const user = userEvent.setup()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Skip' }))
expect(mocks.controller.end).toHaveBeenCalledWith('skip')
})
it('advances on Next before the last step', async () => {
store.isActive = true
store.steps = [promptStep, runStep, videoResultStep]
store.stepIndex = 0
const user = userEvent.setup()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Next' }))
expect(mocks.controller.advance).toHaveBeenCalledOnce()
})
it('shows Complete and finishes the tour on the last step', async () => {
store.isActive = true
store.steps = [runStep, videoResultStep]
store.stepIndex = 1
const user = userEvent.setup()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Finish' }))
expect(mocks.controller.end).toHaveBeenCalledWith('done')
})
it('hides Back on the first step', async () => {
store.isActive = true
store.steps = [promptStep, runStep, videoResultStep]
store.stepIndex = 0
renderOverlay()
await screen.findByRole('button', { name: 'Next' })
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull()
})
it('shows Back after the first step and goes back on press', async () => {
store.isActive = true
store.steps = [promptStep, runStep, videoResultStep]
store.stepIndex = 1
const user = userEvent.setup()
renderOverlay()
await user.click(await screen.findByRole('button', { name: 'Back' }))
expect(mocks.controller.back).toHaveBeenCalledOnce()
})
it('points the decorative agent cursor at the focused target', async () => {
mocks.maskRects = [rect(0)]
store.isActive = true
store.steps = [promptStep, runStep]
renderOverlay()
const cursor = await screen.findByTestId('onboarding-cursor')
expect(cursor).toHaveAttribute('aria-hidden', 'true')
})
it('hides the cursor but keeps the coach-mark and Skip reachable with no target', async () => {
mocks.maskRects = []
store.isActive = true
store.steps = [promptStep, runStep]
renderOverlay()
expect(await screen.findByRole('button', { name: 'Skip' })).toBeVisible()
expect(screen.queryByTestId('onboarding-cursor')).toBeNull()
})
it('offers no Next escape on the Run step (the run is the only way forward)', async () => {
store.isActive = true
store.steps = [promptStep, runStep, imageResultStep]
store.stepIndex = 1
renderOverlay()
await screen.findByText('Press Run to start generating your result')
expect(screen.queryByRole('button', { name: 'Next' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Finish' })).toBeNull()
// Skip and Back remain so the user is never trapped.
expect(screen.getByRole('button', { name: 'Skip' })).toBeVisible()
expect(screen.getByRole('button', { name: 'Back' })).toBeVisible()
})
it('lights the action bar on the Result step without ringing it', async () => {
const actionbar = document.createElement('div')
actionbar.setAttribute('data-testid', 'comfy-actionbar')
actionbar.getBoundingClientRect = () =>
({ left: 5, top: 5, width: 80, height: 8 }) as DOMRect
document.body.append(actionbar)
mocks.maskRects = [rect(0)] // the sink node
store.isActive = true
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
try {
renderOverlay()
// Holes: sink node + action bar. Ring: the sink node only.
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-hole')).toHaveLength(2)
})
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
} finally {
actionbar.remove()
}
})
it('spotlights only the sink on the Result step when no run bar is present', async () => {
mocks.maskRects = [rect(0)]
store.isActive = true
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
renderOverlay()
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
})
it('keeps the result copy and shows a generating indicator while the run is live', async () => {
store.isActive = true
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
store.resultMedia = null
store.runFinished = false
renderOverlay()
expect(
await screen.findByText(
'Your new image lands right here — ready to download or share.'
)
).toBeInTheDocument()
expect(await screen.findByText('Generating…')).toBeInTheDocument()
})
it('stops generating once the run reports, even if no media was captured', async () => {
// The capture polls the sink and gives up silently on timeout. Hanging the
// spinner off the media left it running forever when no URL ever landed.
store.isActive = true
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
store.resultMedia = null
store.runFinished = true
renderOverlay()
await screen.findByText(
'Your new image lands right here — ready to download or share.'
)
expect(screen.queryByText('Generating…')).toBeNull()
})
it('stops generating once the media lands', async () => {
store.isActive = true
store.steps = [runStep, imageResultStep]
store.stepIndex = 1
store.resultMedia = { url: 'blob:result', kind: 'image' }
store.runFinished = true
renderOverlay()
await screen.findByText(
'Your new image lands right here — ready to download or share.'
)
expect(screen.queryByText('Generating…')).toBeNull()
})
it('hides Skip and Back on the Result step so Complete is the only exit', async () => {
store.isActive = true
store.steps = [promptStep, runStep, imageResultStep]
store.stepIndex = 2
renderOverlay()
expect(await screen.findByRole('button', { name: 'Finish' })).toBeVisible()
expect(screen.queryByRole('button', { name: 'Skip' })).toBeNull()
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull()
})
it('hides the ring and holds the coach-mark when the target is panned off screen', async () => {
// The user can pan the spotlit node out of view mid-step. The ring must not be
// drawn off screen, and the mark must hold rather than chase a target that
// isn't there.
mocks.maskRects = [rect(0)]
store.isActive = true
store.steps = [promptStep, runStep]
store.stepIndex = 0
renderOverlay()
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
mocks.maskRects = [{ left: -900, top: -900, width: 100, height: 50 }]
await vi.waitFor(() => {
expect(screen.queryByTestId('onboarding-spotlight')).toBeNull()
})
expect(screen.queryByTestId('onboarding-cursor')).toBeNull()
const mark = screen.getByTestId('onboarding-coach-mark')
expect(mark.style.top).toBe('50%')
expect(mark.style.left).toBe('50%')
})
it('keeps the coach-mark on screen when the target fills the viewport', async () => {
// A zoomed-in node can be larger than the canvas region; the mark must still be
// fully placed inside it rather than spilling off an edge.
mocks.viewport = { left: 0, top: 40, width: 800, height: 600 }
mocks.maskRects = [{ left: 0, top: 40, width: 800, height: 600 }]
store.isActive = true
store.steps = [promptStep, runStep]
store.stepIndex = 0
renderOverlay()
const mark = await screen.findByTestId('onboarding-coach-mark')
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
const top = Number.parseFloat(mark.style.top)
const left = Number.parseFloat(mark.style.left)
expect(top).toBeGreaterThanOrEqual(mocks.viewport.top)
expect(left).toBeGreaterThanOrEqual(mocks.viewport.left)
})
it('zooms in once, then pans without re-zooming on later steps', async () => {
// Reserving room on the first framing sizes the node so the mark fits beside
// it; later steps pass none, which pans at the scale already reached.
mocks.maskRects = [rect(0)]
store.isActive = true
store.steps = [promptStep, runStep, imageResultStep]
store.stepIndex = 0
renderOverlay()
await vi.waitFor(() => expect(mocks.focusNodes).toHaveBeenCalled())
expect(mocks.focusNodes.mock.calls[0][1]).toEqual(
expect.objectContaining({ width: expect.any(Number) })
)
mocks.focusNodes.mockClear()
store.stepIndex = 2
await vi.waitFor(() => expect(mocks.focusNodes).toHaveBeenCalled())
expect(mocks.focusNodes.mock.calls[0][1]).toBeUndefined()
})
it('does not move the camera on the Run step, which points at the toolbar', async () => {
store.isActive = true
store.steps = [promptStep, runStep]
store.stepIndex = 0
renderOverlay()
await vi.waitFor(() => expect(mocks.focusNodes).toHaveBeenCalled())
mocks.focusNodes.mockClear()
store.stepIndex = 1
await screen.findByText('Press Run to start generating your result')
expect(mocks.focusNodes).not.toHaveBeenCalled()
})
it('spotlights the toolbar Run button on the Run step', async () => {
const runButton = document.createElement('div')
runButton.setAttribute('data-testid', 'queue-button')
runButton.getBoundingClientRect = () =>
({ left: 10, top: 20, width: 40, height: 16 }) as DOMRect
document.body.append(runButton)
store.isActive = true
store.steps = [runStep]
try {
renderOverlay()
await vi.waitFor(() => {
expect(screen.getAllByTestId('onboarding-spotlight')).toHaveLength(1)
})
} finally {
runButton.remove()
}
})
})

View File

@@ -0,0 +1,441 @@
<template>
<Teleport v-if="isActive" to="body">
<div
class="pointer-events-none fixed inset-0 z-3000"
role="dialog"
:aria-label="t('onboardingTour.overlayLabel')"
>
<svg class="absolute inset-0 size-full" aria-hidden="true">
<defs>
<mask id="onboarding-tour-spotlight">
<rect width="100%" height="100%" fill="white" />
<rect
v-for="(hole, i) in visibleHoleRects"
:key="i"
data-testid="onboarding-hole"
:x="hole.left"
:y="hole.top"
:width="hole.width"
:height="hole.height"
rx="12"
fill="black"
/>
</mask>
</defs>
<rect
width="100%"
height="100%"
:class="
cn(
'fill-base-background/95',
!reduceMotion && 'transition-opacity duration-500 ease-out',
revealed ? 'opacity-100' : 'opacity-0'
)
"
mask="url(#onboarding-tour-spotlight)"
/>
</svg>
<div
v-for="(hole, i) in visibleSpotRects"
:key="i"
data-testid="onboarding-spotlight"
:class="
cn(
'absolute border-node-component-outline',
!reduceMotion && 'transition-opacity duration-300 ease-out',
revealed ? 'opacity-100' : 'opacity-0',
isRunStep ? 'rounded-lg border' : 'rounded-xl border-2'
)
"
:style="ringStyle(hole)"
/>
<div
ref="bubbleRef"
data-testid="onboarding-coach-mark"
:class="
cn(
'absolute w-full max-w-xs',
!reduceMotion &&
(markGlides
? 'transition-[top,left,opacity] ease-in-out'
: 'transition-opacity duration-300 ease-out'),
copyVisible
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0'
)
"
:style="bubbleStyle"
tabindex="-1"
aria-live="polite"
>
<i
v-if="cursorEdgeClass"
data-testid="onboarding-cursor"
:class="
cn(
'absolute icon-[lucide--mouse-pointer-2] size-4 text-base-foreground drop-shadow-md',
cursorEdgeClass
)
"
aria-hidden="true"
/>
<span
v-if="isGenerating"
class="absolute top-4 right-4 z-10 flex items-center gap-1.5 text-xs text-muted-foreground"
>
<DotSpinner :size="12" />
{{ t('onboardingTour.generating') }}
</span>
<CoachmarkCard
:subtitle="
t('onboardingTour.stepCounter', {
current: stepIndex + 1,
total: totalSteps
})
"
:title="t(copy.title)"
:message="t(copy.body)"
>
<template #actions>
<Button
v-if="!isResultStep"
variant="textonly"
size="md"
class="font-normal"
@click="controller.end('skip')"
>
{{ t('onboardingTour.skip') }}
</Button>
<div class="ml-auto flex items-center gap-2">
<Button
v-if="stepIndex > 0 && !isResultStep"
variant="secondary"
size="md"
class="gap-1 border border-muted-background px-3 py-2 font-normal"
@click="controller.back()"
>
<i
class="icon-[lucide--arrow-left] size-4"
aria-hidden="true"
/>
{{ t('onboardingTour.back') }}
</Button>
<Button
v-if="showNextButton"
variant="inverted"
size="md"
class="gap-1 px-3 py-2 font-normal"
@click="onNext"
>
<i
v-if="isLastStep"
class="icon-[lucide--check] size-4"
aria-hidden="true"
/>
{{
isLastStep
? t('onboardingTour.complete')
: t('onboardingTour.next')
}}
<i
v-if="!isLastStep"
class="icon-[lucide--arrow-right] size-4"
aria-hidden="true"
/>
</Button>
</div>
</template>
</CoachmarkCard>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import {
autoUpdate,
flip,
hide,
offset,
shift,
useFloating
} from '@floating-ui/vue'
import type { VirtualElement } from '@floating-ui/vue'
import {
useElementBounding,
usePreferredReducedMotion,
useResizeObserver
} from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import DotSpinner from '@/components/common/DotSpinner.vue'
import Button from '@/components/ui/button/Button.vue'
import CoachmarkCard from '@/platform/onboarding/CoachmarkCard.vue'
import {
COACH_MARK_GAP,
canvasElement,
focusNodes
} from './canvasSpotlightAdapter'
import type { ScreenRect } from './canvasSpotlightAdapter'
import { MARK_GLIDE_MS, useTourChoreography } from './useTourChoreography'
import { useFirstRunTourController } from './useFirstRunTourController'
import { useFirstRunTourStore } from './firstRunTourStore'
import { useTourSpotlightRects } from './useTourSpotlightRects'
import type { TourStep } from './tourSequence'
const VIEWPORT_MARGIN = 12
const { t } = useI18n()
const store = useFirstRunTourStore()
const controller = useFirstRunTourController()
const {
isActive,
stepIndex,
revealedNodeIds,
spotlitNodeIds,
currentStep,
totalSteps,
resultMedia,
runFinished,
tourRunId
} = storeToRefs(store)
const isLastStep = computed(() => stepIndex.value >= totalSteps.value - 1)
const isRunStep = computed(() => currentStep.value?.kind === 'run')
const isResultStep = computed(() => currentStep.value?.kind === 'result')
/** Gated on the run reporting, not on media: a timed-out capture must not spin forever. */
const isGenerating = computed(
() => isResultStep.value && !runFinished.value && !resultMedia.value
)
/** The Run step advances on click, so it offers no Next. */
const showNextButton = computed(() => !isRunStep.value)
function stepCopyKey(step: TourStep): { title: string; body: string } {
const base = `onboardingTour.step.${step.kind}`
switch (step.kind) {
case 'upload':
case 'prompt': {
const shape = step.shape ?? 'other'
return { title: `${base}.${shape}.title`, body: `${base}.${shape}.body` }
}
case 'run':
return { title: `${base}.title`, body: `${base}.body` }
case 'result': {
const media = step.mediaKind ?? 'image'
return { title: `${base}.${media}.title`, body: `${base}.${media}.body` }
}
}
}
const copy = computed(() =>
currentStep.value ? stepCopyKey(currentStep.value) : { title: '', body: '' }
)
function onNext() {
if (isLastStep.value) {
controller.end('done')
} else {
void controller.advance()
}
}
// Called at setup, not inside the computed: a computed getter has no effect scope,
// so the matchMedia listener VueUse registers there would never be disposed.
const preferredMotion = usePreferredReducedMotion()
const reduceMotion = computed(() => preferredMotion.value === 'reduce')
const bubbleRef = useTemplateRef<HTMLElement>('bubbleRef')
const { width: bubbleWidth, height: bubbleHeight } =
useElementBounding(bubbleRef)
/** True once the tour has zoomed in; later steps pan at that scale. */
const hasZoomed = ref(false)
/**
* Frame the current step, reserving the mark's real footprint on the first framing
* so the node is sized to leave room for it. Later steps pan at that scale.
*/
function frameTarget() {
const reserve = {
width: bubbleWidth.value + COACH_MARK_GAP,
height: bubbleHeight.value + COACH_MARK_GAP
}
focusNodes([...spotlitNodeIds.value], hasZoomed.value ? undefined : reserve)
hasZoomed.value = true
}
const choreography = useTourChoreography({
reduceMotion,
frameTarget,
isStatic: isRunStep
})
const { revealed, copyVisible, markGlides } = choreography
const rects = useTourSpotlightRects({
isRunStep,
isResultStep,
revealedNodeIds,
spotlitNodeIds,
onFrame: choreography.sampleFrame
})
const { focusRect } = rects
/** Holes are cut only once the steps begin, so the intro preview reads undimmed. */
const visibleHoleRects = computed(() =>
revealed.value ? rects.holeRects.value : []
)
const visibleSpotRects = computed(() =>
revealed.value ? rects.visibleSpotRects.value : []
)
// A canvas node has no DOM element; one stable VirtualElement reads the live spotlit
// rect so autoUpdate tracks the camera each frame without re-initialising during motion.
const referenceEl: VirtualElement = {
getBoundingClientRect: () => {
const rect = focusRect.value
return rect
? new DOMRect(rect.left, rect.top, rect.width, rect.height)
: new DOMRect()
}
}
const reference = computed<VirtualElement | null>(() =>
focusRect.value ? referenceEl : null
)
const { floatingStyles, middlewareData, placement } = useFloating(
reference,
bubbleRef,
{
strategy: 'fixed',
// Position via top/left, not transform, so the mark's glide transition animates.
transform: false,
// The Run button lives in the top toolbar, so its mark sits below it.
placement: () => (isRunStep.value ? 'bottom' : 'right-start'),
middleware: [
offset(COACH_MARK_GAP),
flip({ fallbackPlacements: ['left-start', 'bottom', 'top'] }),
shift({ crossAxis: true, padding: VIEWPORT_MARGIN }),
hide()
],
whileElementsMounted: (ref_, floating, update) =>
autoUpdate(ref_, floating, update, { animationFrame: true })
}
)
/** True while the target is clipped out of view; the ring hides and the mark recentres. */
const targetHidden = computed(
() =>
!reference.value || (middlewareData.value.hide?.referenceHidden ?? false)
)
watch(
[isActive, stepIndex, tourRunId],
([active, , runId], previous) => {
if (!active) {
choreography.endTour()
hasZoomed.value = false
return
}
// A fresh tour, not a step change. Keyed to the run id rather than an
// idle→active edge: `start()` passes through idle within one tick, so the
// watcher only ever sees active→active and would carry the last tour's zoom in.
if (previous?.[2] === runId) {
choreography.resetStep()
choreography.beginStep()
return
}
hasZoomed.value = false
choreography.openTour()
},
{ immediate: true }
)
watch(
[isActive, revealedNodeIds, spotlitNodeIds, currentStep],
([active]) => (active ? rects.start() : rects.stop()),
{ immediate: true }
)
// animateToBounds captures the canvas size when the tween starts, so a resize
// mid-flight lands the camera short. Re-frame against the new size.
useResizeObserver(
computed(() => (isActive.value ? canvasElement() : null)),
() => {
if (!isActive.value || choreography.isAwaitingSettle()) return
if (!revealed.value || isRunStep.value || !hasZoomed.value) return
focusNodes([...spotlitNodeIds.value])
}
)
// Routing away mid-tour must end the tour, so the controller's grace timer can't
// outlive the UI it drives.
onUnmounted(() => {
choreography.clearTimers()
if (isActive.value) controller.end('skip')
})
/** Px the node ring sits outside the box, mirroring litegraph's selection overlay. */
const NODE_RING_OFFSET = 3
/** The node ring frames the box from outside; the Run button ring hugs it tightly. */
function ringStyle(rect: ScreenRect) {
const offset = isRunStep.value ? 0 : NODE_RING_OFFSET
return {
left: `${rect.left - offset}px`,
top: `${rect.top - offset}px`,
width: `${rect.width + offset * 2}px`,
height: `${rect.height + offset * 2}px`
}
}
// Shares MARK_GLIDE_MS with the camera delay, so the mark lands before it starts.
const bubbleStyle = computed(() => {
const glide = markGlides.value
? { transitionDuration: `${MARK_GLIDE_MS}ms` }
: {}
if (targetHidden.value) {
return {
...glide,
position: 'fixed' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)'
}
}
return { ...glide, ...floatingStyles.value }
})
/** Floats the cursor mid-gap on the target-facing edge, tip rotated toward the node. */
const CURSOR_EDGE_CLASS: Record<'top' | 'bottom' | 'left' | 'right', string> = {
top: '-top-7 left-1/2 -translate-x-1/2 rotate-45',
bottom: '-bottom-7 left-1/2 -translate-x-1/2 -rotate-[135deg]',
left: '-left-7 top-1/2 -translate-y-1/2 -rotate-45',
right: '-right-7 top-1/2 -translate-y-1/2 rotate-[135deg]'
}
// The card sits on one side of the target; the cursor points back from the opposite edge.
const OPPOSITE_EDGE: Record<string, 'top' | 'bottom' | 'left' | 'right'> = {
top: 'bottom',
bottom: 'top',
left: 'right',
right: 'left'
}
const cursorEdgeClass = computed(() => {
if (targetHidden.value) return ''
const side = placement.value.split('-')[0]
const edge = OPPOSITE_EDGE[side]
return edge ? CURSOR_EDGE_CLASS[edge] : ''
})
</script>

View File

@@ -0,0 +1,92 @@
<template>
<div
v-if="skeleton"
:data-testid="testid"
class="relative aspect-square overflow-hidden rounded-2xl"
>
<Skeleton class="absolute inset-0 rounded-2xl" />
<Skeleton class="absolute inset-x-4 bottom-4 h-4 w-2/3 rounded-md" />
</div>
<div
v-else
role="button"
:tabindex="loading ? -1 : 0"
:aria-disabled="loading"
:data-testid="testid"
:aria-label="title"
:aria-busy="loading"
class="group/card focus-visible:ring-ring relative aspect-square cursor-pointer overflow-hidden rounded-2xl focus-visible:ring-1 focus-visible:outline-none"
@click="onSelect"
@keydown.enter.prevent="onSelect"
@keydown.space.prevent="onSelect"
>
<LazyImage
:src="imageSrc"
:alt="title"
image-class="size-full object-cover opacity-60 transition-all duration-300 ease-out group-hover/card:scale-105 group-hover/card:opacity-100"
/>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-0 bg-linear-to-b from-black/40 via-transparent via-50% to-black/40 transition-opacity duration-300 ease-out group-hover/card:opacity-60"
/>
<div
v-if="badgeIcon"
aria-hidden="true"
data-testid="getting-started-card-badge"
class="pointer-events-none absolute top-3 right-3 flex size-7 items-center justify-center rounded-full bg-black/50 text-base-foreground backdrop-blur-sm"
>
<i :class="cn(badgeIcon, 'size-4')" />
</div>
<h3
class="absolute inset-x-0 bottom-0 m-0 truncate p-4 text-sm font-semibold text-base-foreground drop-shadow-md"
:title
>
{{ title }}
</h3>
<div
v-if="loading"
aria-live="polite"
class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-base-background/70 backdrop-blur-md"
>
<DotSpinner :size="24" />
<span class="text-xs font-medium text-base-foreground/80">
{{ t('onboardingTour.preparing') }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import DotSpinner from '@/components/common/DotSpinner.vue'
import LazyImage from '@/components/common/LazyImage.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
const {
imageSrc = '',
title = '',
loading = false,
skeleton = false,
badgeIcon = '',
testid
} = defineProps<{
imageSrc?: string
title?: string
loading?: boolean
skeleton?: boolean
badgeIcon?: string
testid?: string
}>()
const emit = defineEmits<{ select: [] }>()
const { t } = useI18n()
function onSelect() {
if (loading) return
emit('select')
}
</script>

View File

@@ -0,0 +1,34 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { useOnboardingEntryStore } from '@/platform/workflow/persistence/onboardingEntryStore'
import GettingStartedScreen from './GettingStartedScreen.vue'
const meta: Meta<typeof GettingStartedScreen> = {
title: 'Renderer/OnboardingTour/GettingStartedScreen',
component: GettingStartedScreen,
parameters: {
layout: 'fullscreen',
backgrounds: { default: 'dark' }
},
decorators: [
() => {
useOnboardingEntryStore().showGettingStarted()
// Template cards need the served template package; without a backend the
// curated lookups return nothing, so this catalogs the screen chrome
// (heading, tabs, placeholders). Card rendering is covered by the unit
// test.
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
const render: Story['render'] = () => ({
components: { GettingStartedScreen },
template: '<GettingStartedScreen />'
})
export const Default: Story = { render }

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