Compare commits

...

115 Commits

Author SHA1 Message Date
pythongosssss
4904084ca1 Merge branch 'feat/app-mode-get-started-landing' into pysssss/app-mode-onboarding
# Conflicts:
#	src/components/custom/widget/WorkflowTemplateSelectorDialog.vue
#	src/platform/workflow/templates/utils/templateUtil.ts
#	src/renderer/extensions/linearMode/LinearWelcome.vue
#	src/views/LinearView.vue
2026-07-08 03:53:15 -07:00
pythongosssss
3bac9b96ee Merge branch 'pysssss/product-coachmarks' into pysssss/app-mode-onboarding
# Conflicts:
#	src/renderer/extensions/linearMode/LinearControls.vue
#	src/views/LinearView.vue
2026-07-08 03:51:44 -07:00
pythongosssss
e86c4f7a41 Merge branch 'pysssss/appmode-credit-use-updates' into pysssss/app-mode-onboarding
# Conflicts:
#	src/locales/en/main.json
2026-07-08 03:48:42 -07:00
pythongosssss
91648f1578 Merge branch 'pysssss/template-type-filter' into pysssss/app-mode-onboarding 2026-07-08 03:47:41 -07:00
pythongosssss
d359b81aca Merge branch 'pysssss/app-mode-generation-animation' into pysssss/app-mode-onboarding
# Conflicts:
#	browser_tests/tests/collapsedNodeLinks.spec.ts-snapshots/subgraph-entry-collapsed-node-links-chromium-linux.png
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-default-50-chromium-linux.png
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-25-chromium-linux.png
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-75-chromium-linux.png
#	browser_tests/tests/painter.spec.ts-snapshots/painter-after-resize-chromium-linux.png
#	browser_tests/tests/painter.spec.ts-snapshots/painter-after-stroke-chromium-linux.png
#	browser_tests/tests/painter.spec.ts-snapshots/painter-default-state-chromium-linux.png
#	browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-linux.png
#	browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts-snapshots/vue-nodes-paned-with-touch-mobile-chrome-linux.png
#	browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts-snapshots/vue-node-multiple-promoted-previews-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png
#	browser_tests/tests/vueNodes/rerouteNodeSize.spec.ts-snapshots/vue-reroute-node-compact-chromium-linux.png
2026-07-08 03:47:41 -07:00
pythongosssss
80c7fd86ac Merge branch 'pysssss/app-mode-ui-updates' into pysssss/app-mode-onboarding 2026-07-08 03:47:39 -07:00
pythongosssss
eebf020295 Merge remote-tracking branch 'origin/main' into feat/app-mode-get-started-landing 2026-07-08 03:47:12 -07:00
pythongosssss
cdfb85f7c3 Merge remote-tracking branch 'origin/main' into pysssss/product-coachmarks 2026-07-08 03:47:11 -07:00
pythongosssss
6b1da3fab6 Merge remote-tracking branch 'origin/main' into pysssss/appmode-credit-use-updates 2026-07-08 03:47:11 -07:00
pythongosssss
9939628abf Merge remote-tracking branch 'origin/main' into pysssss/template-type-filter 2026-07-08 03:47:10 -07:00
pythongosssss
aac73da51f Merge remote-tracking branch 'origin/main' into pysssss/app-mode-generation-animation
# Conflicts:
#	browser_tests/tests/collapsedNodeLinks.spec.ts-snapshots/subgraph-entry-collapsed-node-links-chromium-linux.png
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-default-50-chromium-linux.png
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-25-chromium-linux.png
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-75-chromium-linux.png
#	browser_tests/tests/load3d/load3d.spec.ts-snapshots/load3d-background-image-panorama-chromium-linux.png
#	browser_tests/tests/load3d/load3d.spec.ts-snapshots/load3d-background-image-tiled-chromium-linux.png
#	browser_tests/tests/painter.spec.ts-snapshots/painter-after-resize-chromium-linux.png
#	browser_tests/tests/painter.spec.ts-snapshots/painter-after-stroke-chromium-linux.png
#	browser_tests/tests/painter.spec.ts-snapshots/painter-default-state-chromium-linux.png
#	browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-linux.png
#	browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts-snapshots/vue-nodes-paned-with-touch-mobile-chrome-linux.png
#	browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts-snapshots/vue-node-multiple-promoted-previews-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png
#	browser_tests/tests/vueNodes/rerouteNodeSize.spec.ts-snapshots/vue-reroute-node-compact-chromium-linux.png
2026-07-08 03:47:10 -07:00
pythongosssss
3b11c570eb Merge remote-tracking branch 'origin/main' into pysssss/app-mode-ui-updates 2026-07-08 03:47:08 -07: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
c3101cf2c4 remove rerun 2026-07-08 02:38:46 -07:00
pythongosssss
d79bad1dcc remove in progress item progress, change to spinner 2026-07-08 02:31:59 -07:00
pythongosssss
53c6741db5 scope check per row 2026-07-07 11:14:01 -07:00
pythongosssss
28f8f14005 test coverage 2026-07-07 10:34:07 -07:00
pythongosssss
8ac2c2088b tidy 2026-07-07 09:51:13 -07:00
pythongosssss
ba1f9c2719 fix tests 2026-07-07 09:22:24 -07:00
pythongosssss
3afb8f0494 fix mobile credit button color 2026-07-07 09:08:15 -07:00
pythongosssss
162f8643d2 test: cover GeneratingCard and GeneratingScreen
- GeneratingCard: latent/image src, video vs image, icon fallback, load reveal
- GeneratingScreen: fan filtering (skeletons and preview-less latents dropped),
  newest-painted-last ordering, status fallback, progress, and stop emit
2026-07-07 08:47:17 -07:00
pythongosssss
77f531803c refactor: drop the custom Tooltip component for the v-tooltip directive
- Replace the reka-based Tooltip wrapper (a per-instance TooltipProvider)
  with the standard v-tooltip directive on LinearPreview's action buttons
- Each button already carries a matching aria-label, so no a11y change
- Removes src/components/ui/tooltip/Tooltip.vue, its only consumer
2026-07-07 08:47:11 -07:00
pythongosssss
e0300c62a1 chore: revert hand-edited non-English locale files
- Restore the 12 non-en main.json files; only en/main.json should be
  edited by hand, CI regenerates the rest from it
2026-07-07 07:37:41 -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
e366813388 fix: guard credits summary against uninitialized graph
- Return no badges when the graph isn't ready yet, before mapAllNodes
  dereferences app.graph.nodes
- Fixes a render-before-init crash in app mode and the LinearControls suite
2026-07-07 06:31:38 -07:00
pythongosssss
c2874f241a test: assert single shared toolbar and fix view-mode toggle comments
- Assert exactly one side-toolbar is mounted across a graph/app mode flip
- Correct fixture/spec comments: the toggle mounts a separate instance per
  host and unmounts on switch, it does not teleport between hosts
2026-07-07 06:19:02 -07:00
pythongosssss
5344f0f1f5 remove gradient background, add dark canvas color
restructure empty state panels + add border for contrast
2026-07-06 14:10:24 -07:00
pythongosssss
bbfd1c8eee trim comments 2026-07-06 13:15:45 -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
a280153715 feat: add template type filter with apps tab Explore entry
- Add Type filter (All | App | Node graph) to the template browser,
  last in the filter row, via a new template-type stage in
  useTemplateFiltering
- Thread initialTemplateType through useWorkflowTemplateSelectorDialog
  so entry points can pre-filter the dialog
- Add Explore button to the apps tab empty state, opening the template
  browser pre-filtered to apps; equal-width Create app / Explore
  buttons via a new NoResultsPlaceholder `actions` slot
- Move isAppTemplate to templates/utils/templateUtil.ts
2026-07-03 07:56:21 -07:00
pythongosssss
84608a1c74 fix failing test due to larger button 2026-07-03 06:24:35 -07:00
github-actions
ced6ee3ea8 [automated] Update test expectations 2026-07-03 12:33:31 +00:00
github-actions
203648082f [automated] Update test expectations 2026-07-03 11:36:45 +00:00
pythongosssss
0bdf1ec375 use util for locator 2026-07-03 04:21:15 -07:00
pythongosssss
bfc370a903 stub tooltip 2026-07-03 03:59:03 -07:00
pythongosssss
96cbd46906 refactor tests to use fixtures 2026-07-03 03:39:12 -07:00
github-actions
83649da662 [automated] Update test expectations 2026-07-03 10:04:15 +00:00
pythongosssss
f0a1ef8e32 Merge remote-tracking branch 'origin/main' into pysssss/app-mode-ui-updates
# Conflicts:
#	browser_tests/tests/collapsedNodeLinks.spec.ts-snapshots/subgraph-entry-collapsed-node-links-chromium-linux.png
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-default-50-chromium-linux.png
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-25-chromium-linux.png
#	browser_tests/tests/imageCompare.spec.ts-snapshots/image-compare-slider-75-chromium-linux.png
#	browser_tests/tests/load3d/load3d.spec.ts-snapshots/load3d-background-image-panorama-chromium-linux.png
#	browser_tests/tests/load3d/load3d.spec.ts-snapshots/load3d-background-image-tiled-chromium-linux.png
#	browser_tests/tests/painter.spec.ts-snapshots/painter-after-resize-chromium-linux.png
#	browser_tests/tests/painter.spec.ts-snapshots/painter-after-stroke-chromium-linux.png
#	browser_tests/tests/painter.spec.ts-snapshots/painter-default-state-chromium-linux.png
#	browser_tests/tests/saveImageAndWebp.spec.ts-snapshots/save-image-and-webm-preview-chromium-linux.png
#	browser_tests/tests/vueNodes/groups/groups.spec.ts-snapshots/vue-groups-fit-to-contents-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/canvas/pan.spec.ts-snapshots/vue-nodes-paned-with-touch-mobile-chrome-linux.png
#	browser_tests/tests/vueNodes/interactions/canvas/zoom.spec.ts-snapshots/zoomed-in-ctrl-shift-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-ctrl-alt-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-input-drag-reuses-origin-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-input-drag-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-reroute-output-shift-drag-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-shift-output-multi-link-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-node-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/links/linkInteraction.spec.ts-snapshots/vue-node-snap-to-slot-chromium-linux.png
#	browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts-snapshots/vue-node-multiple-promoted-previews-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts-snapshots/vue-node-bypassed-state-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-color-blue-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-dark-all-colors-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/colors.spec.ts-snapshots/vue-node-custom-colors-light-all-colors-chromium-linux.png
#	browser_tests/tests/vueNodes/nodeStates/mute.spec.ts-snapshots/vue-node-muted-state-chromium-linux.png
#	browser_tests/tests/vueNodes/rerouteNodeSize.spec.ts-snapshots/vue-reroute-node-compact-chromium-linux.png
2026-07-03 02:50:18 -07:00
pythongosssss
ac713c2699 refactor: address app mode landing review feedback
- Extract pure template helpers from useTemplateWorkflows into templateUtil
- Disable get-started actions while a template loads, toast on failure
- Open workflow import via Comfy.OpenWorkflow command
- Collapse duplicated welcome card markup into a single shell
- Replace off-scale arbitrary Tailwind values with scale utilities
- Test ready-to-run state, no-app-template fallback, load guard, and helpers
- Add e2e covering featured template load
2026-07-02 13:05:58 -07:00
pythongosssss
57de75748e feat: icon-only output action bar, right-aligned and always visible
- replace text buttons with icon buttons + reka tooltips (new ui/tooltip component)
- order: more, rerun, reuse, share, download; disabled without selection
- add always-active share button opening the workflow share dialog
- keep bar visible during generation
- fix LinearPreview test for GeneratingScreen replacing LatentPreview
2026-07-02 13:05:54 -07:00
pythongosssss
ec269512e7 fix test 2026-07-02 12:17:32 -07:00
pythongosssss
417f2d7b5c refactor: simplify generating-card image reveal, bound extra cards
- Replace the parent-side decode gate (decoded-src map, ready/decoding sets, liveness pruning) with a per-card image fade
- GeneratingCard derives its own src and fades it in on load; GeneratingScreen just filters cards that have content to show
- Latent-preview swaps stay flash-free via the browser keeping the prior frame until the newer src decodes — no manual bookkeeping
- Cap generatingExtraCards on insert instead of slicing at read time, so a long run no longer retains every non-selected output in memory
- Add an interactive GeneratingScreen story (add / remove / reset) to exercise the fan entrance, reflow, eviction and exit animations
2026-07-02 11:57:48 -07:00
pythongosssss
c9d02d1661 feat: add animated outputs
- add generating screen fan of images with pop in animations
- track arrival order to display cards ordered in fan
2026-07-02 11:57:48 -07:00
pythongosssss
6c2ab519ac fix: derive view-mode toggle behavior from real mode, not display lag
- Real mode drives segment behavior/aria; lagged mirror only drives the morph
- Popup semantics on the active button, menu anchored via reference prop;
  drops the stopPropagation guards
- Move viewMode/displayViewMode from canvasStore to appModeStore
- SideToolbar prop-driven (hideWorkspaceToggles); parameterized toggle aria-label
- Tests: drop utility-class asserts and Button stubs, cover missed branches
2026-07-02 10:56:29 -07:00
pythongosssss
9dde0c193d feat: polish App mode landing states
- Style built-app message as a card ("Your app is ready to run", play icon)
- Solid bg on build-prompt card; radial-gradient bg on #linearCenterPanel
- Splitter bg -> base-background (theme-aware)
- Refresh welcome i18n copy; prune stale welcome keys across locales
2026-07-02 09:45:27 -07:00
pythongosssss
5da6a0389e update bg 2026-07-02 07:55:40 -07:00
pythongosssss
6dc61c6ddb remove 2026-07-02 07:50:15 -07:00
pythongosssss
44d066d134 remove conflicting bgs 2026-07-02 07:35:16 -07:00
pythongosssss
2c638d67c3 remove bg color change 2026-07-02 06:40:07 -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
1993bf4290 feat(appMode): rename run count to "Generations" and inline with stepper
- Relabel linear-mode run count from "Number of runs" to "Generations", uppercased
- Inline the label with the number stepper on the desktop run panel (flex wrap)
- Shrink label to text-xs in the foreground color
- Tighten vertical spacing and drop the top separator
2026-07-01 12:51:15 -07:00
pythongosssss
927f3f8541 feat(appMode): credits pill + breakdown popover on the run button
Surface a "Uses credits" pill on the app-mode run/subscribe button and a
per-node cost breakdown popover for priced (partner) nodes.

- Add CreditsPill ("Uses credits") to the run/subscribe button; collapses to a coin badge when the button is tight
- Extract useCreditsSummary; make PartnerNodesList a presentational breakdown with an approximate-cost info tooltip
- Show the breakdown via hover card (desktop) / popover (mobile), opening instantly when credits are required
- Add a brand-yellow button variant and apply it to the subscribe-to-run button
- Drop the subscribe-button tooltip when it only repeats the visible label
- Add e2e coverage for the credit breakdown popover
2026-07-01 12:25:43 -07:00
pythongosssss
979a832845 refactor(ui): unify credit icon to lucide--coins
Add a shared CREDITS_ICON constant and use it for the credit icons across balance, pricing, top-up and credit-slider surfaces, replacing the mixed comfy--credits / lucide--component usages.
2026-07-01 12:23:00 -07:00
pythongosssss
1c1c257f92 feat: redesign App mode empty-graph landing
- Empty graph: new "Get started with Apps" page (templates, import, discover)
- Populated: "Make this workflow an App" build-prompt card
- Share template source/app/thumbnail helpers via useTemplateWorkflows
- Drop unused welcome i18n keys and back-to-workflow button
- Update e2e selectors/specs; add unit tests
2026-07-01 11:25:19 -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
6455a49f58 test(appMode): drop ErrorOverlay assertion from LinearView
ErrorOverlay was removed from LinearView in #12557 (replaced by the app
mode run validation warning); update the merged test to match.

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

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

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

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

@@ -70,11 +70,6 @@ export class OutputHistoryComponent {
return this.page.getByTestId(ids.headerProgressBar)
}
/** The in-progress item's progress bar (inside the thumbnail). */
get itemProgressBar(): Locator {
return this.inProgressItems.first().getByTestId(ids.itemProgressBar)
}
/** Overall progress in the header bar. */
get headerOverallProgress(): Locator {
return this.headerProgressBar.getByTestId(ids.progressOverall)
@@ -84,14 +79,4 @@ export class OutputHistoryComponent {
get headerNodeProgress(): Locator {
return this.headerProgressBar.getByTestId(ids.progressNode)
}
/** Overall progress in the in-progress item bar. */
get itemOverallProgress(): Locator {
return this.itemProgressBar.getByTestId(ids.progressOverall)
}
/** Node progress in the in-progress item bar. */
get itemNodeProgress(): Locator {
return this.itemProgressBar.getByTestId(ids.progressNode)
}
}

View File

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

View File

@@ -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

@@ -0,0 +1,40 @@
import type { Locator, Page } from '@playwright/test'
import { TestIds } from '@e2e/fixtures/selectors'
/**
* The graph/app view-mode toggle and its workflow actions dropdown.
* A separate instance mounts in each host - the subgraph breadcrumb (graph
* mode) and the app-mode center panel - and unmounts as the mode flips.
*/
export class WorkflowActionsDropdown {
/** The segmented graph/app toggle hosting the workflow actions trigger. */
public readonly viewModeToggle: Locator
/** The active segment; opens the workflow actions menu. */
public readonly trigger: Locator
/** The inactive segment that switches into app mode. */
public readonly enterAppModeSegment: Locator
/** The inactive segment that switches back to the node graph. */
public readonly enterGraphSegment: Locator
/** The workflow actions dropdown menu. */
public readonly menu: Locator
constructor(page: Page) {
this.viewModeToggle = page.getByTestId(
TestIds.workflowActions.viewModeToggle
)
this.trigger = this.triggerIn(this.viewModeToggle)
this.enterAppModeSegment = this.viewModeToggle.getByRole('button', {
name: 'Enter app mode'
})
this.enterGraphSegment = this.viewModeToggle.getByRole('button', {
name: 'Enter node graph'
})
this.menu = page.getByRole('menu', { name: 'Workflow actions' })
}
/** The trigger as rendered inside a specific mode's host. */
triggerIn(host: Locator): Locator {
return host.getByRole('button', { name: 'Workflow actions' })
}
}

View File

@@ -4,6 +4,7 @@ import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { OutputHistoryComponent } from '@e2e/fixtures/components/OutputHistory'
import { WorkflowActionsDropdown } from '@e2e/fixtures/components/WorkflowActionsDropdown'
import { AppModeWidgetHelper } from '@e2e/fixtures/helpers/AppModeWidgetHelper'
import { BuilderFooterHelper } from '@e2e/fixtures/helpers/BuilderFooterHelper'
import { BuilderSaveAsHelper } from '@e2e/fixtures/helpers/BuilderSaveAsHelper'
@@ -19,6 +20,7 @@ export class AppModeHelper {
readonly outputHistory: OutputHistoryComponent
readonly steps: BuilderStepsHelper
readonly widgets: AppModeWidgetHelper
readonly workflowActions: WorkflowActionsDropdown
/** The "Connect an output" popover shown when saving without outputs. */
public readonly connectOutputPopover: Locator
@@ -42,16 +44,16 @@ export class AppModeHelper {
public readonly imagePickerPopover: Locator
/** The Run button in the app mode footer. */
public readonly runButton: Locator
/** The welcome screen shown when app mode has no outputs or no nodes. */
/** The welcome card shown when the graph has nodes or outputs (build prompt / ready to run). */
public readonly welcome: Locator
/** The empty workflow message shown when no nodes exist. */
public readonly emptyWorkflowText: Locator
/** The "Build app" button shown when nodes exist but no outputs. */
public readonly buildAppButton: Locator
/** The "Back to workflow" button on the welcome screen. */
public readonly backToWorkflowButton: Locator
/** The "Load template" button shown when no nodes exist. */
public readonly loadTemplateButton: Locator
/** The get started page shown when the graph is empty. */
public readonly getStarted: Locator
/** The "Discover all templates" button on the get started page. */
public readonly getStartedDiscoverButton: Locator
/** Featured template cards on the get started page. */
public readonly getStartedTemplateCards: Locator
/** The cancel button for an in-progress run in the output history. */
public readonly cancelRunButton: Locator
/** Arrange-step placeholder shown when outputs are configured but no run has happened. */
@@ -77,6 +79,7 @@ export class AppModeHelper {
this.outputHistory = new OutputHistoryComponent(comfyPage.page)
this.steps = new BuilderStepsHelper(comfyPage)
this.widgets = new AppModeWidgetHelper(comfyPage)
this.workflowActions = new WorkflowActionsDropdown(comfyPage.page)
this.connectOutputPopover = this.page.getByTestId(
TestIds.builder.connectOutputPopover
@@ -111,15 +114,13 @@ export class AppModeHelper {
.getByTestId(TestIds.linear.runButton)
.getByRole('button', { name: /run/i })
this.welcome = this.page.getByTestId(TestIds.appMode.welcome)
this.emptyWorkflowText = this.page.getByTestId(
TestIds.appMode.emptyWorkflow
)
this.buildAppButton = this.page.getByTestId(TestIds.appMode.buildApp)
this.backToWorkflowButton = this.page.getByTestId(
TestIds.appMode.backToWorkflow
this.getStarted = this.page.getByTestId(TestIds.appMode.getStarted)
this.getStartedDiscoverButton = this.page.getByTestId(
TestIds.appMode.getStartedDiscover
)
this.loadTemplateButton = this.page.getByTestId(
TestIds.appMode.loadTemplate
this.getStartedTemplateCards = this.page.getByTestId(
TestIds.appMode.getStartedTemplate
)
this.cancelRunButton = this.page.getByTestId(
TestIds.outputHistory.cancelRun
@@ -185,10 +186,7 @@ export class AppModeHelper {
.waitFor({ state: 'hidden', timeout: 5000 })
.catch(() => {})
await this.page
.getByRole('button', { name: 'Workflow actions' })
.first()
.click()
await this.workflowActions.trigger.first().click()
await this.page
.getByRole('menuitem', { name: /Build app|Edit app/ })
.click()

View File

@@ -211,17 +211,16 @@ export const TestIds = {
videoOutput: 'linear-video-output',
cancelRun: 'linear-cancel-run',
headerProgressBar: 'linear-header-progress-bar',
itemProgressBar: 'linear-item-progress-bar',
progressOverall: 'linear-progress-overall',
progressNode: 'linear-progress-node'
},
appMode: {
widgetItem: 'app-mode-widget-item',
welcome: 'linear-welcome',
emptyWorkflow: 'linear-welcome-empty-workflow',
buildApp: 'linear-welcome-build-app',
backToWorkflow: 'linear-welcome-back-to-workflow',
loadTemplate: 'linear-welcome-load-template',
getStarted: 'linear-get-started',
getStartedDiscover: 'linear-get-started-discover',
getStartedTemplate: 'linear-get-started-template',
arrangePreview: 'linear-arrange-preview',
arrangeNoOutputs: 'linear-arrange-no-outputs',
arrangeSwitchToOutputs: 'linear-arrange-switch-to-outputs',
@@ -238,6 +237,9 @@ export const TestIds = {
renameInput: 'subgraph-breadcrumb-rename-input',
menu: (key: string) => `subgraph-breadcrumb-menu-${key}`
},
workflowActions: {
viewModeToggle: 'view-mode-toggle'
},
templates: {
content: 'template-workflows-content',
workflowCard: (id: string) => `template-workflow-${id}`

View File

@@ -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

@@ -1,9 +1,14 @@
import { mergeTests } from '@playwright/test'
import {
comfyPageFixture as test,
comfyExpect as expect
comfyExpect as expect,
comfyPageFixture
} from '@e2e/fixtures/ComfyPage'
import { subgraphBreadcrumbFixture } from '@e2e/fixtures/helpers/SubgraphBreadcrumbHelper'
import { TestIds } from '@e2e/fixtures/selectors'
const test = mergeTests(comfyPageFixture, subgraphBreadcrumbFixture)
test.describe('App mode usage', () => {
test('Drag and Drop @vue-nodes', async ({ comfyPage, comfyFiles }) => {
const { centerPanel } = comfyPage.appMode
@@ -137,6 +142,120 @@ test.describe('App mode usage', () => {
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
})
test('Shares the graph side toolbar, filtered to assets + apps', async ({
comfyPage
}) => {
const { sideToolbar, nodeLibraryTab, assetsTab, appsTab } = comfyPage.menu
await test.step('Graph mode shows the full toolbar', async () => {
await expect(sideToolbar).toHaveCount(1)
await expect(nodeLibraryTab.tabButton).toBeVisible()
})
await test.step('App mode reuses it with only assets + apps', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// The same single instance is reused across the mode flip, not a second
// toolbar mounted alongside the first.
await expect(sideToolbar).toHaveCount(1)
await expect(assetsTab.tabButton).toBeVisible()
await expect(appsTab.tabButton).toBeVisible()
await expect(nodeLibraryTab.tabButton).toBeHidden()
})
})
test('Workflow actions menu keeps the same position across graph/app mode', async ({
comfyPage,
subgraphBreadcrumb
}) => {
const { workflowActions, centerPanel } = comfyPage.appMode
// Toggling graph<->app mode happens from this control, so it must not move
// out from under the cursor as the mode flips.
const graphActions = workflowActions.triggerIn(
subgraphBreadcrumb.panel.root
)
await expect(graphActions).toBeVisible()
const graphBox = await graphActions.boundingBox()
expect(graphBox).not.toBeNull()
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(centerPanel).toBeVisible()
const appActions = workflowActions.triggerIn(centerPanel)
await expect(appActions).toBeVisible()
// The toggle segments reorder (morph) as the mode flips, so poll until the
// active control settles at the same x it occupied in graph mode.
await expect
.poll(async () => {
const box = await appActions.boundingBox()
return box ? Math.abs(box.x - graphBox!.x) : Infinity
})
.toBeLessThanOrEqual(1)
})
test('Toggle segment flips mode without opening the menu', async ({
comfyPage
}) => {
const { workflowActions } = comfyPage.appMode
await expect(workflowActions.viewModeToggle).toBeVisible()
await workflowActions.enterAppModeSegment.click()
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// The inactive segment switches mode; it must not also open the actions menu.
await expect(workflowActions.menu).toBeHidden()
await expect(workflowActions.viewModeToggle).toBeVisible()
})
test('Toggle segment flips mode via keyboard without opening the menu', async ({
comfyPage
}) => {
const { workflowActions } = comfyPage.appMode
await workflowActions.enterAppModeSegment.focus()
await workflowActions.enterAppModeSegment.press('Enter')
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// Keyboard activation of the inactive segment must switch mode without the
// keydown bubbling to the trigger and opening the actions menu.
await expect(workflowActions.menu).toBeHidden()
})
test('Mode toggle re-appears after exiting the builder to graph mode', async ({
comfyPage
}) => {
const toggle = comfyPage.appMode.workflowActions.viewModeToggle
await comfyPage.appMode.enableLinearMode()
await expect(toggle).toBeVisible()
await comfyPage.appMode.enterBuilder()
await expect(toggle).toBeHidden()
await expect(comfyPage.appMode.centerPanel).toBeHidden()
await comfyPage.appMode.footer.exitButton.click()
// Exiting the builder lands in graph mode: the app-mode-only center panel
// stays hidden while the graph-mode toggle host re-mounts and the toggle
// re-appears.
await expect(toggle).toBeVisible()
await expect(comfyPage.appMode.centerPanel).toBeHidden()
})
test('Mode toggle survives a sidebar tab remounting the app panel', async ({
comfyPage
}) => {
const toggle = comfyPage.appMode.workflowActions.viewModeToggle
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(toggle).toBeVisible()
// Opening a sidebar tab remounts the app panel; the toggle re-renders with it.
await comfyPage.menu.assetsTab.tabButton.click()
await expect(toggle).toBeVisible()
})
test.describe('Mobile', { tag: ['@mobile'] }, () => {
test('panel navigation', async ({ comfyPage }) => {
const { mobile } = comfyPage.appMode
@@ -184,3 +303,45 @@ test.describe('App mode usage', () => {
})
})
})
test.describe('App mode credits', () => {
const API_PRICED_NODE = 'FluxProUltraImageNode'
test('shows the credit breakdown popover for priced nodes', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.NodeBadge.ShowApiPricing', true)
await comfyPage.page.evaluate((type) => {
const registered = window.LiteGraph!.registered_node_types[type] as {
nodeData?: { price_badge?: unknown }
}
if (!registered?.nodeData) throw new Error(`No nodeData for ${type}`)
registered.nodeData.price_badge = {
engine: 'jsonata',
expr: "{'type': 'text', 'text': '99.9 credits/Run'}",
depends_on: { widgets: [], inputs: [], input_groups: [] }
}
}, API_PRICED_NODE)
await comfyPage.nodeOps.addNode(API_PRICED_NODE)
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
// The run/subscribe button flags that the workflow needs credits, even when
// the pill collapses to its icon (kept in the accessible name).
const runButton = comfyPage.appMode.runButton
await expect(runButton).toBeVisible()
await expect(runButton).toHaveAccessibleName(/Uses credits/)
// Hovering the button reveals the per-node credit breakdown.
await runButton.hover()
const breakdown = comfyPage.page.getByRole('list', {
name: 'Credit breakdown by model'
})
await expect(breakdown).toBeVisible()
await expect(breakdown).toContainText('99.9 credits/Run')
await expect(
comfyPage.page.getByText('Requires additional credits')
).toBeVisible()
})
})

View File

@@ -9,14 +9,12 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await comfyPage.appMode.suppressVueNodeSwitchPopup()
})
test('Empty workflow text is visible when no nodes', async ({
comfyPage
}) => {
test('Get started page is visible when no nodes', async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeVisible()
await expect(comfyPage.appMode.getStarted).toBeVisible()
await expect(comfyPage.appMode.welcome).toBeHidden()
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
})
@@ -27,35 +25,41 @@ test.describe('App mode welcome states', { tag: '@ui' }, () => {
await expect(comfyPage.appMode.welcome).toBeVisible()
await expect(comfyPage.appMode.buildAppButton).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
await expect(comfyPage.appMode.getStarted).toBeHidden()
})
test('Empty workflow and build app are hidden when app has outputs', async ({
test('Get started and build app are hidden when app has outputs', async ({
comfyPage
}) => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.linearWidgets).toBeVisible()
await expect(comfyPage.appMode.emptyWorkflowText).toBeHidden()
await expect(comfyPage.appMode.getStarted).toBeHidden()
await expect(comfyPage.appMode.buildAppButton).toBeHidden()
})
test('Back to workflow returns to graph mode', async ({ comfyPage }) => {
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await comfyPage.appMode.backToWorkflowButton.click()
await expect(comfyPage.canvas).toBeVisible()
await expect(comfyPage.appMode.welcome).toBeHidden()
})
test('Load template opens template selector', async ({ comfyPage }) => {
test('Clicking a featured template loads it into the graph', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.welcome).toBeVisible()
await comfyPage.appMode.loadTemplateButton.click()
await comfyPage.appMode.getStartedTemplateCards.first().click()
await expect(comfyPage.appMode.getStarted).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount())
.toBeGreaterThan(0)
})
test('Discover all templates opens template selector', async ({
comfyPage
}) => {
await comfyPage.nodeOps.clearGraph()
await comfyPage.appMode.toggleAppMode()
await expect(comfyPage.appMode.getStarted).toBeVisible()
await comfyPage.appMode.getStartedDiscoverButton.click()
await expect(comfyPage.templates.content).toBeVisible()
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -46,7 +46,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage, maskEditor }) => {
const { nodeId } = await maskEditor.loadImageOnNode()
await comfyPage.canvasOps.pan({ x: 0, y: 40 }, { x: 300, y: 300 })
// Center the node so its header clears the view-mode toggle floating
// at the top-left of the canvas.
const nodeRef = await comfyPage.nodeOps.getNodeRefById(nodeId)
await nodeRef.centerOnNode()
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)

View File

@@ -362,13 +362,8 @@ test.describe('Output History', { tag: '@ui' }, () => {
const ws = await getWebSocket()
const { exec, jobId } = await startExecution(comfyPage, ws)
const {
inProgressItems,
headerOverallProgress,
headerNodeProgress,
itemOverallProgress,
itemNodeProgress
} = comfyPage.appMode.outputHistory
const { inProgressItems, headerOverallProgress, headerNodeProgress } =
comfyPage.appMode.outputHistory
await expect(inProgressItems.first()).toBeVisible()
@@ -381,7 +376,6 @@ test.describe('Output History', { tag: '@ui' }, () => {
exec.progress(jobId, KSAMPLER_NODE, 5, 10)
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*50%/)
await expect(itemNodeProgress).toHaveAttribute('style', /width:\s*50%/)
// Overall still 0% - no nodes completed yet
await expect(headerOverallProgress).toHaveAttribute('style', /width:\s*0%/)
@@ -391,13 +385,11 @@ test.describe('Output History', { tag: '@ui' }, () => {
const oneNodePercent = Math.round((1 / ALL_NODE_IDS.length) * 100)
const pct = new RegExp(`width:\\s*${oneNodePercent}%`)
await expect(headerOverallProgress).toHaveAttribute('style', pct)
await expect(itemOverallProgress).toHaveAttribute('style', pct)
// Node progress reaches 100%
exec.progress(jobId, KSAMPLER_NODE, 10, 10)
await expect(headerNodeProgress).toHaveAttribute('style', /width:\s*100%/)
await expect(itemNodeProgress).toHaveAttribute('style', /width:\s*100%/)
// Complete remaining nodes - overall reaches 100%
const remainingNodes = ALL_NODE_IDS.filter((id) => id !== KSAMPLER_NODE)
@@ -410,6 +402,5 @@ test.describe('Output History', { tag: '@ui' }, () => {
'style',
/width:\s*100%/
)
await expect(itemOverallProgress).toHaveAttribute('style', /width:\s*100%/)
})
})

View File

@@ -0,0 +1,136 @@
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.page.getByRole('button', { name: 'Media Assets' }).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()
// Advancing to the next step moves focus onto its primary action.
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)
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -72,6 +72,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:",

View File

@@ -11,6 +11,7 @@
--color-charcoal-600: #262729;
--color-charcoal-700: #202121;
--color-charcoal-800: #171718;
--color-charcoal-900: #141414;
--color-neutral-550: #636363;

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);
@@ -457,6 +461,7 @@
);
--color-interface-menu-surface: var(--interface-menu-surface);
--color-interface-menu-stroke: var(--interface-menu-stroke);
--color-interface-canvas-background: var(--color-charcoal-900);
--color-interface-panel-surface: var(--interface-panel-surface);
--color-interface-panel-hover-surface: var(--interface-panel-hover-surface);
--color-interface-panel-selected-surface: var(

6
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
@@ -465,6 +468,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

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

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

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

View File

@@ -28,6 +28,8 @@ const formatNumber = ({
return new Intl.NumberFormat(locale, merged).format(value)
}
export const CREDITS_ICON = 'icon-[lucide--coins]'
export const CREDITS_PER_USD = 211
export const COMFY_CREDIT_RATE_CENTS = CREDITS_PER_USD / 100 // credits per cent

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,14 +12,16 @@
<p :class="textClass" class="text-center whitespace-pre-line">
{{ message }}
</p>
<Button
v-if="buttonLabel"
:variant="buttonVariant ?? 'textonly'"
@click="$emit('action')"
>
<i v-if="buttonIcon" :class="buttonIcon" />
{{ buttonLabel }}
</Button>
<slot name="actions">
<Button
v-if="buttonLabel"
:variant="buttonVariant ?? 'textonly'"
@click="$emit('action')"
>
<i v-if="buttonIcon" :class="buttonIcon" />
{{ buttonLabel }}
</Button>
</slot>
</div>
</template>
</Card>

View File

@@ -14,7 +14,7 @@
class="p-1 text-amber-400"
>
<template #icon>
<i class="icon-[lucide--component]" />
<i :class="CREDITS_ICON" />
</template>
</Tag>
<div :class="textClass">
@@ -29,7 +29,10 @@ import Tag from 'primevue/tag'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import {
CREDITS_ICON,
formatCreditsFromCents
} from '@/base/credits/comfyCredits'
import { useAuthStore } from '@/stores/authStore'
const { textClass, showCreditsOnly } = defineProps<{

View File

@@ -0,0 +1,222 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { ViewMode } from '@/utils/appMode'
import WorkflowActionsDropdown from './WorkflowActionsDropdown.vue'
const spies = vi.hoisted(() => ({
execute: vi.fn(),
trackUiButtonClicked: vi.fn(),
markAsSeen: vi.fn()
}))
const viewState = vi.hoisted(() => ({
viewMode: 'graph' as ViewMode,
displayViewMode: 'graph' as ViewMode
}))
vi.mock('@/stores/appModeStore', async () => {
const { computed, reactive } = await import('vue')
return {
useAppModeStore: () =>
reactive({
viewMode: computed(() => viewState.viewMode),
displayViewMode: computed(() => viewState.displayViewMode)
})
}
})
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute: spies.execute, commands: [] })
}))
vi.mock('@/platform/keybindings/keybindingStore', () => ({
useKeybindingStore: () => ({
getKeybindingByCommandId: () => ({ combo: { toString: () => 'Ctrl+L' } })
})
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackUiButtonClicked: spies.trackUiButtonClicked })
}))
vi.mock('@/composables/useWorkflowActionsMenu', async () => {
const { ref } = await import('vue')
return { useWorkflowActionsMenu: () => ({ menuItems: ref([]) }) }
})
vi.mock('@/composables/useNewMenuItemIndicator', async () => {
const { ref } = await import('vue')
return {
useNewMenuItemIndicator: () => ({
hasUnseenItems: ref(true),
markAsSeen: spies.markAsSeen
})
}
})
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { shortcutSuffix: ' ({shortcut})' },
breadcrumbsMenu: {
graph: 'Graph',
app: 'App',
enterNodeGraph: 'Enter node graph',
enterAppMode: 'Enter app mode',
workflowActions: 'Workflow actions',
activeModeWorkflowActions: '{mode} mode, workflow actions'
}
}
}
})
function renderDropdown() {
const user = userEvent.setup()
const result = render(WorkflowActionsDropdown, {
props: { source: 'test' },
global: {
plugins: [i18n],
directives: { tooltip: {} },
stubs: {
DropdownMenuRoot: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
WorkflowActionsList: true
}
}
})
return { ...result, user }
}
describe('WorkflowActionsDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
viewState.viewMode = 'graph'
viewState.displayViewMode = 'graph'
})
it('keeps the active segment label in its accessible name alongside the actions label', () => {
renderDropdown()
// Graph is the active segment, so its name must contain the visible "Graph"
// label (label-in-name) while still matching the "Workflow actions" trigger.
const active = screen.getByRole('button', { name: /workflow actions/ })
expect(active).toHaveAttribute('aria-label', 'Graph mode, workflow actions')
})
it('labels the inactive segment with its switch action only', () => {
renderDropdown()
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
expect(inactive).toHaveAttribute('aria-label', 'Enter app mode')
})
it('flips the segment roles when app mode is active', () => {
viewState.viewMode = 'app'
viewState.displayViewMode = 'app'
renderDropdown()
const active = screen.getByRole('button', { name: /workflow actions/ })
expect(active).toHaveAttribute('aria-label', 'App mode, workflow actions')
expect(
screen.getByRole('button', { name: 'Enter node graph' })
).toHaveAttribute('aria-label', 'Enter node graph')
})
it('derives the active segment from the real mode, not the lagged display mode', () => {
// Mid-animation: the mode has flipped to app but the display still lags.
viewState.viewMode = 'app'
viewState.displayViewMode = 'graph'
renderDropdown()
const active = screen.getByRole('button', { name: /workflow actions/ })
expect(active).toHaveAttribute('aria-label', 'App mode, workflow actions')
expect(
screen.getByRole('button', { name: 'Enter node graph' })
).toBeInTheDocument()
})
it('carries the popup semantics only on the active segment', () => {
renderDropdown()
const active = screen.getByRole('button', { name: /workflow actions/ })
expect(active).toHaveAttribute('aria-haspopup', 'menu')
expect(active).toHaveAttribute('aria-expanded', 'false')
expect(
screen.getByRole('button', { name: 'Enter app mode' })
).not.toHaveAttribute('aria-haspopup')
})
it('toggles the view mode when the inactive segment is clicked', async () => {
const { user } = renderDropdown()
await user.click(screen.getByRole('button', { name: 'Enter app mode' }))
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
metadata: { source: 'test' }
})
})
it('opens the menu instead of toggling the mode when the active segment is clicked', async () => {
const { user } = renderDropdown()
const active = screen.getByRole('button', { name: /workflow actions/ })
await user.click(active)
expect(spies.execute).not.toHaveBeenCalled()
expect(active).toHaveAttribute('aria-expanded', 'true')
expect(spies.markAsSeen).toHaveBeenCalled()
expect(spies.trackUiButtonClicked).toHaveBeenCalledWith({
button_id: 'test',
element_group: 'workflow_actions'
})
})
it('closes the menu when the open trigger is clicked again', async () => {
const { user } = renderDropdown()
const active = screen.getByRole('button', { name: /workflow actions/ })
await user.click(active)
await user.click(active)
expect(active).toHaveAttribute('aria-expanded', 'false')
})
it('switches mode when the inactive segment is activated by keyboard', async () => {
const { user } = renderDropdown()
const inactive = screen.getByRole('button', { name: 'Enter app mode' })
inactive.focus()
await user.keyboard('{Enter}')
expect(spies.execute).toHaveBeenCalledWith('Comfy.ToggleLinear', {
metadata: { source: 'test' }
})
})
it('does not switch mode when the active segment is activated by keyboard', async () => {
const { user } = renderDropdown()
const active = screen.getByRole('button', { name: /workflow actions/ })
active.focus()
await user.keyboard('{Enter}')
expect(spies.execute).not.toHaveBeenCalled()
})
it('opens the menu on ArrowDown on the active segment', async () => {
const { user } = renderDropdown()
const active = screen.getByRole('button', { name: /workflow actions/ })
active.focus()
await user.keyboard('{ArrowDown}')
expect(active).toHaveAttribute('aria-expanded', 'true')
})
})

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import {
DropdownMenuContent,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
DropdownMenuRoot
} from 'reka-ui'
import { ref } from 'vue'
import type { FocusOutsideEvent, PointerDownOutsideEvent } from 'reka-ui'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
@@ -14,8 +15,21 @@ import { useNewMenuItemIndicator } from '@/composables/useNewMenuItemIndicator'
import { useWorkflowActionsMenu } from '@/composables/useWorkflowActionsMenu'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useCommandStore } from '@/stores/commandStore'
import type { ViewMode } from '@/utils/appMode'
interface ViewModeSegment {
mode: ViewMode
icon: string
label: string
switchLabel: string
switchTooltip: string
/** Drives behavior and aria; flips as soon as the mode changes. */
active: boolean
/** Frame-lagged mirror of {@link active} that drives the morph order. */
displayActive: boolean
}
const { source, align = 'start' } = defineProps<{
source: string
@@ -23,46 +37,117 @@ const { source, align = 'start' } = defineProps<{
}>()
const { t } = useI18n()
const canvasStore = useCanvasStore()
const keybindingStore = useKeybindingStore()
const dropdownOpen = ref(false)
const appModeStore = useAppModeStore()
const { menuItems } = useWorkflowActionsMenu(
() => useCommandStore().execute('Comfy.RenameWorkflow'),
{ isRoot: true }
)
const { hasUnseenItems, markAsSeen } = useNewMenuItemIndicator(
() => menuItems.value
)
function handleOpen(open: boolean) {
if (open) {
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source,
element_group: 'workflow_actions'
})
}
}
function toggleModeTooltip() {
const label = canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
const toggleShortcut = computed(() => {
const shortcut = keybindingStore
.getKeybindingByCommandId('Comfy.ToggleLinear')
?.combo.toString()
return label + (shortcut ? t('g.shortcutSuffix', { shortcut }) : '')
return shortcut ? t('g.shortcutSuffix', { shortcut }) : ''
})
const segments = computed<ViewModeSegment[]>(() =>
(
[
{
mode: 'graph',
icon: 'icon-[comfy--workflow]',
label: t('breadcrumbsMenu.graph'),
switchLabel: t('breadcrumbsMenu.enterNodeGraph'),
switchTooltip:
t('breadcrumbsMenu.enterNodeGraph') + toggleShortcut.value
},
{
mode: 'app',
icon: 'icon-[lucide--panels-top-left]',
label: t('breadcrumbsMenu.app'),
switchLabel: t('breadcrumbsMenu.enterAppMode'),
switchTooltip: t('breadcrumbsMenu.enterAppMode') + toggleShortcut.value
}
] as const
).map((seg) => ({
...seg,
active: appModeStore.viewMode === seg.mode,
displayActive: appModeStore.displayViewMode === seg.mode
}))
)
// Display-inactive segment first (left), display-active last (right). The
// reorder on mode switch is what TransitionGroup FLIP-animates.
const orderedSegments = computed(() => {
const [graph, app] = segments.value
return graph.displayActive ? [app, graph] : [graph, app]
})
const toggleContainer = useTemplateRef<HTMLDivElement>('toggleContainer')
// The active segment is the only element with popup semantics.
function activeSegmentElement() {
return (
toggleContainer.value?.querySelector<HTMLElement>(
'[aria-haspopup="menu"]'
) ?? undefined
)
}
function toggleLinearMode() {
function toggleDropdown() {
dropdownOpen.value = !dropdownOpen.value
if (!dropdownOpen.value) return
markAsSeen()
useTelemetry()?.trackUiButtonClicked({
button_id: source,
element_group: 'workflow_actions'
})
}
function switchMode() {
dropdownOpen.value = false
void useCommandStore().execute('Comfy.ToggleLinear', {
metadata: { source }
})
}
function onSegmentClick(seg: ViewModeSegment) {
if (seg.active) toggleDropdown()
else switchMode()
}
// Match the stock dropdown trigger: ArrowDown on the trigger opens the menu.
function onSegmentKeydown(seg: ViewModeSegment, e: KeyboardEvent) {
if (!seg.active || e.key !== 'ArrowDown') return
e.preventDefault()
if (!dropdownOpen.value) toggleDropdown()
}
// A stock DropdownMenuTrigger would break the FLIP morph, so re-create its two
// behaviors: clicking the open trigger toggles closed (not dismiss-then-reopen),
// and focus returns to the trigger on close unless the user interacted outside.
let interactedOutside = false
function onInteractOutside(event: PointerDownOutsideEvent | FocusOutsideEvent) {
const target = event.target
if (target instanceof Node && activeSegmentElement()?.contains(target)) {
event.preventDefault()
return
}
interactedOutside = true
}
function onCloseAutoFocus(event: Event) {
event.preventDefault()
if (!interactedOutside) activeSegmentElement()?.focus()
interactedOutside = false
}
const tooltipPt = {
root: {
style: {
@@ -75,82 +160,97 @@ const tooltipPt = {
style: { whiteSpace: 'nowrap' }
},
arrow: {
class: '!left-[16px]'
style: { left: '16px' }
}
}
</script>
<template>
<DropdownMenuRoot
v-model:open="dropdownOpen"
:modal="false"
@update:open="handleOpen"
>
<slot name="button" :has-unseen-items="hasUnseenItems">
<div
class="pointer-events-auto inline-flex items-center rounded-lg bg-secondary-background"
<DropdownMenuRoot v-model:open="dropdownOpen" :modal="false">
<div
ref="toggleContainer"
data-testid="view-mode-toggle"
class="group pointer-events-auto relative inline-block rounded-lg bg-base-background p-1"
:data-state="dropdownOpen ? 'open' : 'closed'"
>
<TransitionGroup
tag="div"
move-class="transition-[background-color,color,transform] duration-200"
class="flex items-center gap-1"
>
<Button
v-for="seg in orderedSegments"
:key="seg.mode"
v-tooltip.bottom="{
value: toggleModeTooltip(),
value: seg.active
? t('breadcrumbsMenu.workflowActions')
: seg.switchTooltip,
showDelay: 300,
hideDelay: 300,
pt: tooltipPt
pt: seg.active ? undefined : tooltipPt
}"
type="button"
variant="textonly"
size="unset"
:aria-label="
canvasStore.linearMode
? t('breadcrumbsMenu.enterNodeGraph')
: t('breadcrumbsMenu.enterAppMode')
seg.active
? t('breadcrumbsMenu.activeModeWorkflowActions', {
mode: seg.label
})
: seg.switchLabel
"
variant="base"
class="m-1"
@pointerdown.stop
@click="toggleLinearMode"
:aria-haspopup="seg.active ? 'menu' : undefined"
:aria-expanded="seg.active ? dropdownOpen : undefined"
:class="
cn(
'relative flex h-8 items-center gap-0 rounded-md font-normal transition-[background-color,color,transform] duration-200',
seg.displayActive
? 'bg-secondary-background pr-2 pl-2.5 text-base-foreground group-data-[state=open]:bg-secondary-background-hover group-data-[state=open]:shadow-interface hover:bg-secondary-background'
: 'w-8 justify-center bg-transparent text-muted-foreground hover:bg-secondary-background hover:text-base-foreground'
)
"
@click="onSegmentClick(seg)"
@keydown="onSegmentKeydown(seg, $event)"
>
<i
class="size-4"
<i :class="cn('size-4 shrink-0', seg.icon)" aria-hidden="true" />
<span
:class="
canvasStore.linearMode
? 'icon-[lucide--panels-top-left]'
: 'icon-[comfy--workflow]'
cn(
'grid transition-[grid-template-columns,opacity] duration-200',
seg.displayActive
? 'ml-1.5 grid-cols-[1fr] opacity-100'
: 'grid-cols-[0fr] opacity-0'
)
"
>
<span
class="flex min-w-0 items-center overflow-hidden text-sm leading-none whitespace-nowrap"
>
{{ seg.label }}
<i
class="ml-1 icon-[lucide--chevron-down] size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</span>
</span>
<span
v-if="seg.active && hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
<DropdownMenuTrigger as-child>
<Button
v-tooltip="{
value: t('breadcrumbsMenu.workflowActions'),
showDelay: 300,
hideDelay: 300
}"
variant="secondary"
size="unset"
:aria-label="t('breadcrumbsMenu.workflowActions')"
class="relative h-10 gap-1 rounded-lg pr-2 pl-2.5 text-center data-[state=open]:bg-secondary-background-hover data-[state=open]:shadow-interface"
>
<span>{{
canvasStore.linearMode
? t('breadcrumbsMenu.app')
: t('breadcrumbsMenu.graph')
}}</span>
<i
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
/>
<span
v-if="hasUnseenItems"
aria-hidden="true"
class="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-primary-background"
/>
</Button>
</DropdownMenuTrigger>
</div>
</slot>
</TransitionGroup>
</div>
<DropdownMenuPortal>
<DropdownMenuContent
:align
:side-offset="5"
:aria-label="t('breadcrumbsMenu.workflowActions')"
:reference="toggleContainer ?? undefined"
:side-offset="8"
:collision-padding="10"
class="z-1000 min-w-56 rounded-lg border border-border-subtle bg-base-background px-2 py-3 shadow-interface"
@interact-outside="onInteractOutside"
@close-auto-focus="onCloseAutoFocus"
>
<WorkflowActionsList :items="menuItems" />
</DropdownMenuContent>

View File

@@ -92,6 +92,19 @@
<i class="icon-[lucide--server]" />
</template>
</MultiSelect>
<!-- Template Type Filter -->
<SingleSelect
v-model="selectedTemplateType"
:label="$t('templateWorkflows.typeFilter', 'Type')"
:options="templateTypeOptions"
:content-style="selectContentStyle"
class="w-40"
>
<template #icon>
<i class="icon-[lucide--panels-top-left]" />
</template>
</SingleSelect>
</div>
<!-- Sort Options -->
@@ -427,9 +440,18 @@ import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import type { TemplateTypeFilter } from '@/composables/useTemplateFiltering'
import { useTelemetry } from '@/platform/telemetry'
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import {
getBaseThumbnailSrc,
getEffectiveSourceModule,
getOverlayThumbnailSrc,
getTemplateDescription,
getTemplateTitle,
isAppTemplate
} from '@/platform/workflow/templates/utils/templateUtil'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
import { OnCloseKey } from '@/types/widgetTypes'
@@ -437,9 +459,14 @@ import { createGridStyle } from '@/utils/gridUtil'
const { t } = useI18n()
const { onClose: originalOnClose, initialCategory = 'all' } = defineProps<{
const {
onClose: originalOnClose,
initialCategory = 'all',
initialTemplateType
} = defineProps<{
onClose: () => void
initialCategory?: string
initialTemplateType?: TemplateTypeFilter
}>()
// Track session time for telemetry
@@ -468,28 +495,7 @@ provide(OnCloseKey, onClose)
// Workflow templates store and composable
const workflowTemplatesStore = useWorkflowTemplatesStore()
const {
loadTemplates,
loadWorkflowTemplate,
getTemplateThumbnailUrl,
getTemplateTitle,
getTemplateDescription
} = useTemplateWorkflows()
const getEffectiveSourceModule = (template: TemplateInfo) =>
template.sourceModule || 'default'
const isAppTemplate = (template: TemplateInfo) => template.name.endsWith('.app')
const getBaseThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '1' : '')
}
const getOverlayThumbnailSrc = (template: TemplateInfo) => {
const sm = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(template, sm, sm === 'default' ? '2' : '')
}
const { loadTemplates, loadWorkflowTemplate } = useTemplateWorkflows()
// Open tutorial in new tab
const openTutorial = (template: TemplateInfo) => {
@@ -556,6 +562,7 @@ const {
selectedModels,
selectedUseCases,
selectedRunsOn,
selectedTemplateType,
sortBy,
activeModels,
activeUseCases,
@@ -569,6 +576,10 @@ const {
loadFuseOptions
} = useTemplateFiltering(navigationFilteredTemplates)
if (initialTemplateType) {
selectedTemplateType.value = initialTemplateType
}
/**
* Raw search input bound to the search box. The actual `searchQuery` consumed
* by the filtering composable is only updated via `applySearchQuery` after the
@@ -726,6 +737,15 @@ const runsOnFilterLabel = computed(() => {
}
})
const templateTypeOptions = computed(() => [
{ name: t('templateWorkflows.types.all', 'All'), value: 'all' },
{ name: t('templateWorkflows.types.apps', 'App'), value: 'apps' },
{
name: t('templateWorkflows.types.nodeGraphs', 'Node graph'),
value: 'workflows'
}
])
// Sort options
const sortOptions = computed(() => [
{
@@ -792,7 +812,8 @@ watch(
sortBy,
selectedModels,
selectedUseCases,
selectedRunsOn
selectedRunsOn,
selectedTemplateType
],
() => {
resetPagination()

View File

@@ -86,7 +86,7 @@
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 text-gold-500')" />
</template>
</FormattedNumberStepper>
</div>
@@ -98,7 +98,7 @@
v-if="isBelowMin"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
>
<i class="icon-[lucide--component] size-4" />
<i :class="cn(CREDITS_ICON, 'size-4')" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
@@ -109,7 +109,7 @@
v-if="showCeilingWarning"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
>
<i class="icon-[lucide--component] size-4" />
<i :class="cn(CREDITS_ICON, 'size-4')" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))
@@ -154,7 +154,11 @@ import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import {
CREDITS_ICON,
creditsToUsd,
usdToCredits
} from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'

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

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

View File

@@ -7,7 +7,7 @@
)
"
>
<i class="icon-[lucide--component] h-full bg-amber-400" />
<i :class="cn(CREDITS_ICON, 'h-full bg-amber-400')" />
<span class="truncate" v-text="text" />
</span>
<span
@@ -21,6 +21,8 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
defineProps<{
text: string
rest?: string

View File

@@ -51,7 +51,7 @@
>
<i
aria-hidden="true"
class="icon-[lucide--component] size-3 text-amber-400"
:class="cn(CREDITS_ICON, 'size-3 text-amber-400')"
/>
<i
aria-hidden="true"
@@ -134,6 +134,8 @@ import { getProviderIcon, getProviderName } from '@/utils/categoryUtil'
import { formatNumberWithSuffix, highlightQuery } from '@/utils/formatUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
const {
nodeDef,
currentQuery,

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,207 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import AppsSidebarTab from './AppsSidebarTab.vue'
const execute = vi.hoisted(() => vi.fn())
const workflowStoreState = vi.hoisted(() => ({
persistedWorkflows: [] as ComfyWorkflow[]
}))
vi.mock('@/stores/commandStore', () => ({
useCommandStore: () => ({ execute })
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const { ComfyWorkflow } =
await import('@/platform/workflow/management/stores/comfyWorkflow')
return {
ComfyWorkflow,
useWorkflowStore: () => ({
get workflows() {
return workflowStoreState.persistedWorkflows
},
get persistedWorkflows() {
return workflowStoreState.persistedWorkflows
},
bookmarkedWorkflows: [],
openWorkflows: [],
activeWorkflow: undefined,
isSyncLoading: false,
syncWorkflows: vi.fn()
}),
useWorkflowBookmarkStore: () => ({ loadBookmarks: vi.fn() })
}
})
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({})
}))
vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
useSearchQueryTracking: () => undefined
}))
vi.mock('@/stores/workspaceStore', () => ({
useWorkspaceStore: () => ({ shiftDown: false })
}))
vi.mock('@/composables/useAppMode', async () => {
const { computed } = await import('vue')
return { useAppMode: () => ({ isAppMode: computed(() => true) }) }
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({ get: () => undefined })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
beta: 'Beta',
refresh: 'Refresh',
searchPlaceholder: 'Search {subject}'
},
sideToolbar: {
workflowTab: {
workflowTreeType: {
open: 'Open',
bookmarks: 'Bookmarks',
browse: 'Browse'
}
}
},
linearMode: {
appModeToolbar: {
apps: 'Apps',
create: 'Create',
createApp: 'Create app',
appsEmptyMessage: 'No apps yet',
appsEmptyMessageAction: 'Create one to get started'
}
}
}
}
})
const noResultsPlaceholderStub = {
props: ['buttonLabel'],
emits: ['action'],
template: '<button @click="$emit(\'action\')">{{ buttonLabel }}</button>'
}
function renderTab({ hasResults = true }: { hasResults?: boolean } = {}) {
const user = userEvent.setup()
const result = render(AppsSidebarTab, {
global: {
plugins: [i18n],
stubs: {
BaseWorkflowsSidebarTab: {
template: `<div><slot name="header-actions" :has-results="${hasResults}" /><slot name="empty-state" /></div>`
},
NoResultsPlaceholder: noResultsPlaceholderStub
}
}
})
return { ...result, user }
}
async function makeWorkflow(path: string): Promise<ComfyWorkflow> {
const { ComfyWorkflow } =
await import('@/platform/workflow/management/stores/comfyWorkflow')
return new ComfyWorkflow({ path, modified: 0, size: 1 })
}
function renderTabWithRealBase() {
const user = userEvent.setup()
const result = render(AppsSidebarTab, {
global: {
plugins: [i18n],
directives: { tooltip: {} },
stubs: {
SidebarTabTemplate: {
template:
'<div><slot name="alt-title" /><slot name="tool-buttons" /><slot name="header" /><slot name="body" /></div>'
},
SidebarTopArea: { template: '<div><slot /></div>' },
SearchInput: { template: '<input />', methods: { focus() {} } },
TreeExplorer: { template: '<div data-testid="tree-explorer" />' },
NoResultsPlaceholder: noResultsPlaceholderStub
}
}
})
return { ...result, user }
}
describe('AppsSidebarTab', () => {
beforeEach(() => {
vi.clearAllMocks()
workflowStoreState.persistedWorkflows = []
})
it('shows the create action only when there are results', () => {
const { unmount } = renderTab({ hasResults: false })
expect(
screen.queryByRole('button', { name: 'Create' })
).not.toBeInTheDocument()
unmount()
renderTab({ hasResults: true })
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
})
it('runs the new-workflow command when the create action is clicked', async () => {
const { user } = renderTab({ hasResults: true })
await user.click(screen.getByRole('button', { name: 'Create' }))
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
})
it('runs the new-workflow command from the empty-state action', async () => {
const { user } = renderTab({ hasResults: false })
await user.click(screen.getByRole('button', { name: 'Create app' }))
expect(execute).toHaveBeenCalledWith('Comfy.NewBlankWorkflow')
})
describe('with the real workflows tab', () => {
it('counts only app workflows as results', async () => {
workflowStoreState.persistedWorkflows = [
await makeWorkflow('workflows/my-app.app.json'),
await makeWorkflow('workflows/regular.json')
]
renderTabWithRealBase()
expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Create app' })
).not.toBeInTheDocument()
})
it('shows the empty state when no app workflows exist', async () => {
workflowStoreState.persistedWorkflows = [
await makeWorkflow('workflows/regular.json')
]
renderTabWithRealBase()
expect(
screen.queryByRole('button', { name: 'Create' })
).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Create app' })
).toBeInTheDocument()
})
})
})

View File

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

View File

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

View File

@@ -105,7 +105,8 @@ vi.mock('@/components/common/UserAvatar.vue', () => ({
}
}))
vi.mock('@/base/credits/comfyCredits', () => ({
vi.mock('@/base/credits/comfyCredits', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
formatCreditsFromCents: vi.fn(({ cents }) => (cents / 100).toString())
}))

View File

@@ -31,7 +31,7 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i :class="cn(CREDITS_ICON, 'text-sm text-amber-400')" />
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance
@@ -153,7 +153,12 @@ import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { cn } from '@comfyorg/tailwind-utils'
import {
CREDITS_ICON,
formatCreditsFromCents
} from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'

View File

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

View File

@@ -25,7 +25,8 @@ export const buttonVariants = cva({
tertiary:
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
gradient:
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90',
'brand-yellow': 'bg-brand-yellow text-black hover:bg-brand-yellow/85'
},
size: {
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
@@ -58,7 +59,8 @@ const variants = [
'base',
'tertiary',
'overlay-white',
'gradient'
'gradient',
'brand-yellow'
] as const satisfies Array<ButtonVariants['variant']>
const sizes = [
'sm',

View File

@@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Slider from '@/components/ui/slider/Slider.vue'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
@@ -223,7 +224,8 @@ const { t } = useI18n()
<i
:class="
cn(
'icon-[comfy--credits] size-3 shrink-0',
CREDITS_ICON,
'size-3 shrink-0',
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
)
"

View File

@@ -186,6 +186,39 @@ describe('useTemplateFiltering', () => {
])
})
it('filters templates by type and resets to all types with resetFilters', () => {
const templates = ref<TemplateInfo[]>([
{
name: 'portrait-flow.app',
description: 'App template',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'portrait-flow',
description: 'Node graph template',
mediaType: 'image',
mediaSubtype: 'png'
}
])
const { selectedTemplateType, filteredTemplates, resetFilters } =
useTemplateFiltering(templates)
selectedTemplateType.value = 'apps'
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'portrait-flow.app'
])
selectedTemplateType.value = 'workflows'
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'portrait-flow'
])
resetFilters()
expect(filteredTemplates.value).toHaveLength(2)
})
it('supports alphabetical, newest, and size-based sorting options', async () => {
const templates = ref<TemplateInfo[]>([
{

View File

@@ -9,11 +9,14 @@ import { useTelemetry } from '@/platform/telemetry'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { isAppTemplate } from '@/platform/workflow/templates/utils/templateUtil'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
import { debounce } from 'es-toolkit/compat'
import { api } from '@/scripts/api'
export type TemplateTypeFilter = 'all' | 'apps' | 'workflows'
/**
* Checks whether a template is visible for the given set of distributions.
* Templates without `includeOnDistributions` are visible everywhere.
@@ -57,6 +60,7 @@ export function useTemplateFiltering(
const selectedRunsOn = ref<string[]>(
settingStore.get('Comfy.Templates.SelectedRunsOn')
)
const selectedTemplateType = ref<TemplateTypeFilter>('all')
const sortBy = ref<
| 'default'
| 'recommended'
@@ -221,8 +225,19 @@ export function useTemplateFiltering(
})
})
const filteredByTemplateType = computed(() => {
if (selectedTemplateType.value === 'all') {
return filteredByRunsOn.value
}
const wantApps = selectedTemplateType.value === 'apps'
return filteredByRunsOn.value.filter(
(template) => isAppTemplate(template) === wantApps
)
})
watch(
filteredByRunsOn,
filteredByTemplateType,
(templates) => {
rankingStore.largestUsageScore = Math.max(
...templates.map((t) => t.usage || 0)
@@ -232,7 +247,7 @@ export function useTemplateFiltering(
)
const sortedTemplates = computed(() => {
const templates = [...filteredByRunsOn.value]
const templates = [...filteredByTemplateType.value]
switch (sortBy.value) {
case 'recommended':
@@ -291,6 +306,7 @@ export function useTemplateFiltering(
selectedModels.value = []
selectedUseCases.value = []
selectedRunsOn.value = []
selectedTemplateType.value = 'all'
sortBy.value = 'default'
}
@@ -388,6 +404,7 @@ export function useTemplateFiltering(
selectedModels,
selectedUseCases,
selectedRunsOn,
selectedTemplateType,
sortBy,
// Computed - Active filters (actually applied)

View File

@@ -107,6 +107,21 @@ describe('useWorkflowTemplateSelectorDialog', () => {
)
})
it('passes initialTemplateType through to the dialog props', () => {
mockNewUserService.isNewUser.mockReturnValue(false)
const dialog = useWorkflowTemplateSelectorDialog()
dialog.show('sidebar', { initialTemplateType: 'apps' })
expect(mockDialogService.showLayoutDialog).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
initialTemplateType: 'apps'
})
})
)
})
it('invokes afterClose callback when dialog is closed', () => {
mockNewUserService.isNewUser.mockReturnValue(false)
const afterClose = vi.fn()

View File

@@ -1,4 +1,5 @@
import WorkflowTemplateSelectorDialog from '@/components/custom/widget/WorkflowTemplateSelectorDialog.vue'
import type { TemplateTypeFilter } from '@/composables/useTemplateFiltering'
import { useTelemetry } from '@/platform/telemetry'
import type { TemplateLibraryMetadata } from '@/platform/telemetry/types'
import { useDialogService } from '@/services/dialogService'
@@ -19,7 +20,11 @@ export const useWorkflowTemplateSelectorDialog = () => {
function show(
source: TemplateLibraryMetadata['source'] = 'command',
options?: { initialCategory?: string; afterClose?: () => void }
options?: {
initialCategory?: string
initialTemplateType?: TemplateTypeFilter
afterClose?: () => void
}
) {
useTelemetry()?.trackTemplateLibraryOpened({ source })
@@ -35,7 +40,8 @@ export const useWorkflowTemplateSelectorDialog = () => {
hide()
options?.afterClose?.()
},
initialCategory
initialCategory,
initialTemplateType: options?.initialTemplateType
},
// The template browser is a wide layout. Without an explicit size the
// Reka DialogContent falls back to size 'md' (max-w-xl), clipping the

View File

@@ -1159,6 +1159,12 @@
"useCasesSelected": "{count} Use Cases",
"runsOnSelected": "{count} Runs On",
"runsOnFilter": "Runs on",
"typeFilter": "Type",
"types": {
"all": "All",
"apps": "App",
"nodeGraphs": "Node graph"
},
"resultsCount": "Showing {count} of {total} templates",
"sort": {
"recommended": "Recommended",
@@ -3076,6 +3082,7 @@
"enterBuilderMode": "Build app",
"editBuilderMode": "Edit app",
"workflowActions": "Workflow actions",
"activeModeWorkflowActions": "{mode} mode, workflow actions",
"clearWorkflow": "Clear Workflow",
"deleteWorkflow": "Delete Workflow",
"deleteBlueprint": "Delete Blueprint",
@@ -3697,13 +3704,13 @@
},
"linearMode": {
"linearMode": "App Mode",
"beta": "App mode in beta",
"buildAnApp": "Build an app",
"giveFeedback": "Give feedback",
"graphMode": "Graph Mode",
"dragAndDropImage": "Click to browse or drag an image",
"mobileControls": "Edit & Run",
"runCount": "Number of runs",
"runCount": "Generations",
"generating": "Generating…",
"stopGeneration": "Stop generation",
"rerun": "Rerun",
"reuseParameters": "Reuse Parameters",
"downloadAll": "Download {count} assets from this run",
@@ -3712,26 +3719,39 @@
"emptyWorkflowExplanation": "Your workflow is empty. You need some nodes first to start building an app.",
"backToWorkflow": "Back to workflow",
"loadTemplate": "Load a template",
"cancelThisRun": "Cancel this run",
"deleteAllAssets": "Delete all assets from this run",
"hasCreditCost": "Requires additional credits",
"creditApproximateInfo": "Credit consumption shown here is approximate due to 3rd party provider calculations, exact values are available in your job history once the workflow is executed",
"creditBreakdown": "Credit breakdown by model",
"usesCredits": "Uses credits",
"viewGraph": "View node graph",
"mobileNoWorkflow": "This workflow hasn't been built for app mode. Try a different one.",
"welcome": {
"title": "App Mode",
"message": "A simplified view that hides the node graph so you can focus on creating.",
"controls": "Your outputs appear at the bottom, your controls are on the right. Everything else stays out of the way.",
"sharing": "Share your workflow as a simple tool anyone can use. Export it from the tab menu and when others open it, they'll see App Mode. No node graph knowledge needed.",
"getStarted": "Click {runButton} to get started.",
"buildApp": "Build app",
"noOutputs": "An app needs at least {count} to be usable.",
"oneOutput": "1 output"
"title": "Your app is ready to run",
"description": "Set your inputs then run to get started"
},
"getStarted": {
"title": "Get started with Apps",
"subtitle": "Pick an app template to get started. Each one is built on a workflow.",
"templates": "Templates",
"importWorkflow": "Import workflow",
"discoverAll": "Discover all templates",
"loadFailed": "Couldn't load this template. Please try again."
},
"buildPrompt": {
"title": "Make this workflow an App",
"description": "Pick which nodes become inputs and outputs, and we'll generate a simple form anyone can run.",
"button": "Build your App"
},
"appModeToolbar": {
"appBuilder": "App builder",
"apps": "Apps",
"appsEmptyMessage": "Saved apps will show up here.",
"appsEmptyMessageAction": "Click below to build your first app."
"appsEmptyMessageAction": "Click below to build your first app.",
"buildAnApp": "Build an app",
"create": "Create",
"createApp": "Create app",
"explore": "Explore"
},
"arrange": {
"noOutputs": "No outputs added yet",
@@ -3774,6 +3794,7 @@
"support": "contact our support",
"promptShow": "Show error report"
},
"feedbackLoadError": "Failed to load feedback form. Please try again later.",
"queue": {
"clickToClear": "Click to clear queue",
"clear": "Clear queue"
@@ -4496,5 +4517,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

@@ -10,6 +10,7 @@ import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Slider from '@/components/ui/slider/Slider.vue'
import {
DEFAULT_TEAM_PLAN_STOP_INDEX,
@@ -215,7 +216,8 @@ const { t } = useI18n()
<i
:class="
cn(
'icon-[comfy--credits] size-3 shrink-0',
CREDITS_ICON,
'size-3 shrink-0',
i === selectedIndex ? 'bg-amber-400' : 'bg-muted-foreground'
)
"

View File

@@ -19,7 +19,7 @@
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="flex items-baseline gap-2">
<i class="icon-[lucide--component] size-4 self-center text-credit" />
<i :class="cn(CREDITS_ICON, 'size-4 self-center text-credit')" />
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
<span class="text-sm text-muted @max-[300px]:hidden">{{
$t('subscription.remaining')
@@ -81,7 +81,7 @@
v-else
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--component] size-4 text-credit" />
<i :class="cn(CREDITS_ICON, 'size-4 text-credit')" />
<span class="@max-[180px]:hidden">
{{
$t('subscription.creditsLeftOfTotal', {
@@ -134,7 +134,7 @@
v-else
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--component] size-4 text-credit" />
<i :class="cn(CREDITS_ICON, 'size-4 text-credit')" />
{{ displayPrepaid }}
</span>
</div>
@@ -180,7 +180,7 @@ import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCredits } from '@/base/credits/comfyCredits'
import { CREDITS_ICON, formatCredits } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'

View File

@@ -123,7 +123,7 @@
</span>
<div class="flex flex-row items-center gap-1">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
:class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')"
aria-hidden="true"
/>
<span
@@ -264,6 +264,7 @@ import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useBillingContext } from '@/composables/billing/useBillingContext'

View File

@@ -4,14 +4,20 @@
value: buttonTooltip,
showDelay: 600
}"
class="subscribe-to-run-button h-8 gap-1.5 rounded-lg px-4 whitespace-nowrap"
variant="gradient"
:class="
cn(
'subscribe-to-run-button gap-1.5 rounded-lg px-4 whitespace-nowrap [--credits-pill-base:var(--color-brand-yellow)]',
large ? 'h-10 text-sm' : 'h-8'
)
"
variant="brand-yellow"
size="unset"
data-testid="subscribe-to-run-button"
@click="handleSubscribeToRun"
>
<i class="pi pi-lock" />
{{ buttonLabel }}
<slot name="trailing" />
</Button>
</template>
@@ -25,8 +31,12 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useRunButtonTelemetry } from '@/composables/useRunButtonTelemetry'
import { isCloud } from '@/platform/distribution/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { cn } from '@comfyorg/tailwind-utils'
const { large = false } = defineProps<{ large?: boolean }>()
const { t } = useI18n()
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMdOrLarger = breakpoints.greaterOrEqual('md')
@@ -43,11 +53,13 @@ const buttonLabel = computed(() => {
: t('subscription.subscribeToRun')
})
const buttonTooltip = computed(() =>
canResubscribe.value
const buttonTooltip = computed(() => {
const tooltip = canResubscribe.value
? t('subscription.subscribeToRunFull')
: t('subscription.inactive.memberRunTooltip')
)
// Skip the tooltip when it would only repeat the visible button label
return tooltip === buttonLabel.value ? undefined : tooltip
})
function handleSubscribeToRun() {
if (isCloud) {

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,126 @@
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 } from './onboardingTours'
import { useOnboardingTourStore } from './onboardingTourStore'
vi.mock('./onboardingTourStore', () => ({ useOnboardingTourStore: vi.fn() }))
function makeTourState() {
return {
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,38 @@
<template>
<CoachmarkLanding
v-if="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="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 CoachmarkLanding from './CoachmarkLanding.vue'
import TourSpotlight from './TourSpotlight.vue'
import { useOnboardingTourStore } from './onboardingTourStore'
const tour = useOnboardingTourStore()
</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,227 @@
import { defineStore } from 'pinia'
import { computed, 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
if (!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) {
if (steps.value.length) return
if (!force && hasSeenTour(entryPath)) return
const resolved = resolveSteps(TOURS[entryPath], targetMounted)
if (!resolved.length) return
steps.value = resolved
activeTour.value = entryPath
trackTour('started')
void showStep(0)
}
function replayTour(entryPath: EntryPath) {
startTour(entryPath, true)
}
return {
step,
isLast,
canGoBack,
title,
body,
primaryLabel,
skipLabel,
backLabel,
countedStepIdx,
countedStepsTotal,
waitingForTarget,
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'
/** 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: 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 { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { computed } from 'vue'
import type { ComputedRef } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
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 = useBreakpoints(breakpointsTailwind).greaterOrEqual('md')
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

@@ -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

@@ -16,6 +16,8 @@ import type {
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OnboardingTourMetadata,
OnboardingTourStage,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
@@ -165,6 +167,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,35 @@ 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]
])(
'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()
@@ -700,6 +701,38 @@ 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]
])(
'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

@@ -23,6 +23,8 @@ import type {
NodeAddedMetadata,
NodeSearchMetadata,
NodeSearchResultMetadata,
OnboardingTourMetadata,
OnboardingTourStage,
SearchQueryMetadata,
PageViewMetadata,
PageVisibilityMetadata,
@@ -49,7 +51,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 = [
@@ -416,6 +422,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

@@ -68,6 +68,31 @@ export interface SurveyResponses {
intent?: string[]
}
export type OnboardingTourStage =
| 'started'
| 'step_shown'
| 'completed'
| 'skipped'
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.
*/
export interface OnboardingTourMetadata {
tour: string
step_count: number
step_number?: number
coach_id?: string
skip_reason?: OnboardingTourSkipReason
}
export interface SurveyResponsesNormalized extends SurveyResponses {
industry_normalized?: string
industry_raw?: string
@@ -556,6 +581,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
@@ -661,6 +692,12 @@ export const TelemetryEvents = {
USER_SURVEY_OPENED: 'app:user_survey_opened',
USER_SURVEY_SUBMITTED: 'app:user_survey_submitted',
// Onboarding Coachmarks
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',
// Email Verification
USER_EMAIL_VERIFY_OPENED: 'app:user_email_verify_opened',
USER_EMAIL_VERIFY_REQUESTED: 'app:user_email_verify_requested',
@@ -724,6 +761,16 @@ 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
}
export const CANCELLATION_STAGE_EVENTS = {
flow_opened: TelemetryEvents.SUBSCRIPTION_CANCEL_FLOW_OPENED,
confirmed: TelemetryEvents.SUBSCRIPTION_CANCEL_CONFIRMED,
@@ -743,6 +790,7 @@ export type ExecutionTriggerSource =
*/
export type TelemetryEventProperties =
| AuthMetadata
| OnboardingTourMetadata
| SurveyResponses
| TemplateMetadata
| ExecutionContext

View File

@@ -162,114 +162,6 @@ describe('useTemplateWorkflows', () => {
expect(selectedTemplate.value).toEqual(category)
})
it('should format template thumbnails correctly for default templates', () => {
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
const template = {
name: 'test-template',
mediaSubtype: 'jpg',
mediaType: 'image',
description: 'Test template'
}
const url = getTemplateThumbnailUrl(template, 'default', '1')
expect(url).toBe('mock-file-url/templates/test-template-1.jpg')
})
it('should format template thumbnails correctly for custom templates', () => {
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
const template = {
name: 'test-template',
mediaSubtype: 'jpg',
mediaType: 'image',
description: 'Test template'
}
const url = getTemplateThumbnailUrl(template, 'custom-module')
expect(url).toBe(
'mock-api-url/workflow_templates/custom-module/test-template.jpg'
)
})
it('should format template titles correctly', () => {
const { getTemplateTitle } = useTemplateWorkflows()
// Default template with localized title
const titleWithLocalized = getTemplateTitle(
{
name: 'test',
localizedTitle: 'Localized Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'default'
)
expect(titleWithLocalized).toBe('Localized Title')
// Default template without localized title
const titleWithFallback = getTemplateTitle(
{
name: 'test',
title: 'Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'default'
)
expect(titleWithFallback).toBe('Title')
// Custom template
const customTitle = getTemplateTitle(
{
name: 'test-template',
title: 'Custom Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'custom-module'
)
expect(customTitle).toBe('Custom Title')
// Fallback to name
const nameOnly = getTemplateTitle(
{
name: 'name-only',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'custom-module'
)
expect(nameOnly).toBe('name-only')
})
it('should format template descriptions correctly', () => {
const { getTemplateDescription } = useTemplateWorkflows()
// Default template with localized description
const descWithLocalized = getTemplateDescription({
name: 'test',
localizedDescription: 'Localized Description',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
})
expect(descWithLocalized).toBe('Localized Description')
// Custom template with description
const customDesc = getTemplateDescription({
name: 'test',
description: 'custom-template_description',
mediaType: 'image',
mediaSubtype: 'jpg'
})
expect(customDesc).toBe('custom template description')
})
it('should load a template from the "All" category', async () => {
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()

View File

@@ -5,7 +5,6 @@ import { useTelemetry } from '@/platform/telemetry'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type {
TemplateGroup,
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { api } from '@/scripts/api'
@@ -55,45 +54,6 @@ export function useTemplateWorkflows() {
return category !== null
}
/**
* Gets template thumbnail URL
*/
const getTemplateThumbnailUrl = (
template: TemplateInfo,
sourceModule: string,
index = '1'
) => {
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
}
/**
* Gets formatted template title
*/
const getTemplateTitle = (template: TemplateInfo, sourceModule: string) => {
const fallback =
template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? (template.localizedTitle ?? fallback)
: fallback
}
/**
* Gets formatted template description
*/
const getTemplateDescription = (template: TemplateInfo) => {
return (
(template.localizedDescription || template.description)
?.replace(/[-_]/g, ' ')
.trim() ?? ''
)
}
/**
* Loads a workflow template
*/
@@ -177,9 +137,6 @@ export function useTemplateWorkflows() {
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
getTemplateThumbnailUrl,
getTemplateTitle,
getTemplateDescription,
loadWorkflowTemplate
}
}

View File

@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import {
getBaseThumbnailSrc,
getEffectiveSourceModule,
getOverlayThumbnailSrc,
getTemplateDescription,
getTemplateTitle,
isAppTemplate
} from '@/platform/workflow/templates/utils/templateUtil'
vi.mock('@/scripts/api', () => ({
api: {
fileURL: (path: string) => `mock-file-url${path}`,
apiURL: (path: string) => `mock-api-url${path}`
}
}))
function makeTemplate(overrides: Partial<TemplateInfo> = {}): TemplateInfo {
return {
name: 'test-template',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test template',
...overrides
}
}
describe('getEffectiveSourceModule', () => {
it('returns the template source module when set', () => {
expect(
getEffectiveSourceModule(makeTemplate({ sourceModule: 'custom-module' }))
).toBe('custom-module')
})
it('defaults to the frontend-provided set when unset or empty', () => {
expect(getEffectiveSourceModule(makeTemplate())).toBe('default')
expect(getEffectiveSourceModule(makeTemplate({ sourceModule: '' }))).toBe(
'default'
)
})
})
describe('isAppTemplate', () => {
it('detects the .app name suffix', () => {
expect(isAppTemplate(makeTemplate({ name: 'flux.app' }))).toBe(true)
expect(isAppTemplate(makeTemplate({ name: 'flux' }))).toBe(false)
expect(isAppTemplate(makeTemplate({ name: 'app.flux' }))).toBe(false)
})
})
describe('thumbnail sources', () => {
it('appends -1/-2 index suffixes for default templates', () => {
const template = makeTemplate()
expect(getBaseThumbnailSrc(template)).toBe(
'mock-file-url/templates/test-template-1.jpg'
)
expect(getOverlayThumbnailSrc(template)).toBe(
'mock-file-url/templates/test-template-2.jpg'
)
})
it('uses the unsuffixed API path for custom module templates', () => {
const template = makeTemplate({ sourceModule: 'custom-module' })
const expected =
'mock-api-url/workflow_templates/custom-module/test-template.jpg'
expect(getBaseThumbnailSrc(template)).toBe(expected)
expect(getOverlayThumbnailSrc(template)).toBe(expected)
})
})
describe('getTemplateTitle', () => {
it('prefers the localized title for default templates', () => {
expect(
getTemplateTitle(
makeTemplate({ title: 'Title', localizedTitle: 'Localized Title' }),
'default'
)
).toBe('Localized Title')
})
it('falls back to title then name', () => {
expect(getTemplateTitle(makeTemplate({ title: 'Title' }), 'default')).toBe(
'Title'
)
expect(getTemplateTitle(makeTemplate(), 'custom-module')).toBe(
'test-template'
)
})
it('ignores the localized title for custom module templates', () => {
expect(
getTemplateTitle(
makeTemplate({ title: 'Title', localizedTitle: 'Localized Title' }),
'custom-module'
)
).toBe('Title')
})
})
describe('getTemplateDescription', () => {
it('prefers the localized description', () => {
expect(
getTemplateDescription(
makeTemplate({ localizedDescription: 'Localized Description' })
)
).toBe('Localized Description')
})
it('replaces dashes and underscores with spaces', () => {
expect(
getTemplateDescription(
makeTemplate({ description: 'custom-template_description' })
)
).toBe('custom template description')
})
})

View File

@@ -0,0 +1,78 @@
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
import { api } from '@/scripts/api'
/**
* Source module a template loads from, defaulting to the frontend-provided set.
*/
export function getEffectiveSourceModule(template: TemplateInfo): string {
return template.sourceModule || 'default'
}
/**
* Whether a template targets App mode (name suffixed with `.app`).
*/
export function isAppTemplate(template: TemplateInfo): boolean {
return template.name.endsWith('.app')
}
function getTemplateThumbnailUrl(
template: TemplateInfo,
sourceModule: string,
index = '1'
): string {
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
}
/**
* Primary thumbnail URL for a template.
*/
export function getBaseThumbnailSrc(template: TemplateInfo): string {
const sourceModule = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(
template,
sourceModule,
sourceModule === 'default' ? '1' : ''
)
}
/**
* Secondary/hover thumbnail URL for a template.
*/
export function getOverlayThumbnailSrc(template: TemplateInfo): string {
const sourceModule = getEffectiveSourceModule(template)
return getTemplateThumbnailUrl(
template,
sourceModule,
sourceModule === 'default' ? '2' : ''
)
}
/**
* Formatted template title, preferring the localized title for default templates.
*/
export function getTemplateTitle(
template: TemplateInfo,
sourceModule: string
): string {
const fallback = template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? (template.localizedTitle ?? fallback)
: fallback
}
/**
* Formatted template description, preferring the localized description.
*/
export function getTemplateDescription(template: TemplateInfo): string {
return (
(template.localizedDescription || template.description)
?.replace(/[-_]/g, ' ')
.trim() ?? ''
)
}

View File

@@ -59,7 +59,7 @@
<!-- Credits Section -->
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i :class="cn(CREDITS_ICON, 'text-sm text-amber-400')" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"
@@ -220,7 +220,12 @@ import Skeleton from 'primevue/skeleton'
import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCreditsFromCents } from '@/base/credits/comfyCredits'
import { cn } from '@comfyorg/tailwind-utils'
import {
CREDITS_ICON,
formatCreditsFromCents
} from '@/base/credits/comfyCredits'
import UserAvatar from '@/components/common/UserAvatar.vue'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import WorkspaceSwitcherPopover from '@/platform/workspace/components/WorkspaceSwitcherPopover.vue'

View File

@@ -129,7 +129,7 @@
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i :class="cn(CREDITS_ICON, 'text-sm text-amber-400')" />
<span
class="font-inter text-sm/normal font-bold text-base-foreground"
>
@@ -303,6 +303,7 @@ import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import {

View File

@@ -38,7 +38,7 @@
{{ $t(creditsRefillLabelKey) }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')" />
<span class="font-bold text-base-foreground">
{{ refillCredits }}
</span>
@@ -176,6 +176,8 @@ import type { BillingCycle } from '@/platform/cloud/subscription/utils/subscript
import type { PreviewSubscribeResponse } from '@/platform/workspace/api/workspaceApi'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import SubscriptionTermsNote from './SubscriptionTermsNote.vue'
interface Props {

View File

@@ -28,7 +28,7 @@
</span>
</div>
<div class="flex items-center gap-1 text-sm text-muted-foreground">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')" />
<span>{{ displayCredits }} {{ $t('subscription.perMonth') }}</span>
</div>
</div>
@@ -97,6 +97,9 @@
import { computed, nextTick, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import Button from '@/components/ui/button/Button.vue'
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'

View File

@@ -66,7 +66,7 @@
<div class="flex items-center justify-between">
<span class="text-base-foreground">{{ refillLabel }}</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')" />
<span class="font-bold text-base-foreground">{{
refillCredits
}}</span>
@@ -89,7 +89,7 @@
{{ $t('subscription.preview.creditsRefillMonthlyTo') }}
</span>
<div class="flex items-center gap-1">
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')" />
<span class="font-bold text-base-foreground">{{
monthlyRefillCredits
}}</span>
@@ -147,6 +147,9 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import type { TeamPlanSelection } from '@/platform/cloud/subscription/constants/teamPlanCreditStops'
import { getTierCredits } from '@/platform/cloud/subscription/constants/tierPricing'

View File

@@ -86,7 +86,7 @@
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
<i :class="cn(CREDITS_ICON, 'size-4 shrink-0 text-gold-500')" />
</template>
</FormattedNumberStepper>
</div>
@@ -98,7 +98,7 @@
v-if="isBelowMin"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
>
<i class="icon-[lucide--component] size-4" />
<i :class="cn(CREDITS_ICON, 'size-4')" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
@@ -109,7 +109,7 @@
v-if="showCeilingWarning"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
>
<i class="icon-[lucide--component] size-4" />
<i :class="cn(CREDITS_ICON, 'size-4')" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))
@@ -154,7 +154,11 @@ import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { creditsToUsd, usdToCredits } from '@/base/credits/comfyCredits'
import {
CREDITS_ICON,
creditsToUsd,
usdToCredits
} from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import FormattedNumberStepper from '@/components/ui/stepper/FormattedNumberStepper.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'

View File

@@ -166,7 +166,7 @@
<div class="mt-auto flex flex-col gap-1">
<div class="flex flex-row items-center gap-2">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
:class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')"
aria-hidden="true"
/>
<span
@@ -244,7 +244,7 @@
<div class="flex flex-col gap-1">
<div class="flex flex-row items-center gap-2">
<i
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
:class="cn(CREDITS_ICON, 'size-4 shrink-0 bg-amber-400')"
aria-hidden="true"
/>
<span
@@ -413,6 +413,7 @@ import type { ToggleButtonPassThroughMethodOptions } from 'primevue/togglebutton
import { computed, onMounted, ref, watch } from 'vue'
import { I18nT, useI18n } from 'vue-i18n'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import CreditSlider from '@/components/ui/credit-slider/CreditSlider.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'

View File

@@ -1,6 +1,7 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { nextTick, ref } from 'vue'
import type { Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphCanvas, Positionable } from '@/lib/litegraph/src/litegraph'
@@ -8,9 +9,13 @@ import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
const { appModeState } = vi.hoisted(() => ({
appModeState: {} as { isAppMode: Ref<boolean> }
}))
vi.mock('@/composables/useAppMode', () => ({
useAppMode: () => ({
isAppMode: { value: false },
isAppMode: appModeState.isAppMode,
setMode: vi.fn()
})
}))
@@ -43,6 +48,7 @@ describe('useCanvasStore', () => {
let store: ReturnType<typeof useCanvasStore>
beforeEach(() => {
appModeState.isAppMode = ref(false)
setActivePinia(createTestingPinia({ stubActions: false }))
store = useCanvasStore()
vi.clearAllMocks()

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { CREDITS_ICON } from '@/base/credits/comfyCredits'
const { t } = useI18n()
</script>
<template>
<span
class="flex h-5 min-w-0 flex-wrap content-start items-center justify-end overflow-hidden text-xs font-medium [--pill-fill:color-mix(in_srgb,currentColor_15%,var(--credits-pill-base,var(--color-base-foreground)))]"
>
<span
class="z-10 flex size-5 shrink-0 items-center justify-center rounded-full bg-(--pill-fill)"
>
<i :class="cn(CREDITS_ICON, 'size-3.5 bg-current')" />
</span>
<span
class="-ml-2.5 flex h-5 items-center rounded-r-full bg-(--pill-fill) pr-2.5 pl-3"
>
{{ t('linearMode.usesCredits') }}
</span>
</span>
</template>

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