Compare commits

..

42 Commits

Author SHA1 Message Date
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
Benjamin Lu
fa87c46f90 chore: remove PreToolUse pnpm-enforcement hooks (#13422)
## Summary

Removes the Claude Code PreToolUse hooks added in #11201. Their `if`
patterns blocked any bash command the static pattern parser could not
fully resolve — loop variables, `$(...)`/backtick substitution,
heredocs, `${...}` expansions — with a misleading error naming a random
unrelated tool. Transcript analysis across a month of sessions found 37
hook firings: 1 true positive, 36 false positives (~97%), including
blocking `pnpm typecheck ... | tail` itself.

## Changes

- **What**: Delete `.claude/settings.json` (it contained only the hook
config) and the script-based replacement from earlier revisions of this
PR.
- Earlier revisions replaced the hooks with a stdin-inspecting matcher
script, but the hooks' original rationale — protecting Nx task
orchestration back when `test:unit` was `nx run test` — disappeared when
Nx was removed in #12355 and the pnpm scripts became direct tool
invocations. The remaining value (nudging agents toward pnpm scripts)
does not justify maintaining a bash-parsing matcher with its own edge
cases.

## Review Focus

- Agents can now run `npx tsc` / `npx vitest` etc. without being
redirected; the pnpm-script convention remains documented in AGENTS.md,
which is what agents follow in practice.
2026-07-03 06:57:05 +00:00
Benjamin Lu
941f151520 fix: avoid duplicate unit coverage execution (#13423)
## Summary

Stop running the full Vitest suite twice in unit CI. The critical
coverage gate is now a glob-keyed `coverage.thresholds` entry enforced
during the single `pnpm test:coverage` run, instead of a second
`COVERAGE_CRITICAL=true vitest run --coverage` pass.

## Changes

- **What**: All critical directories form one brace-expanded glob key in
`coverage.thresholds`; Vitest aggregates the matching files into a
single bucket and checks the existing thresholds (69/60/67/70) against
it during the normal coverage run. Untested files matching
`coverage.include` are counted at 0%, preserving the previous gate's
semantics.
- **What**: Narrows the litegraph coverage exclusion from a blanket
`src/lib/litegraph/**` to the non-critical subfolders, so the critical
litegraph folders (`node`, `subgraph`, `utils`) are present in the
coverage report the thresholds read.
- **What**: Removes the `test:coverage:critical` script, the
`COVERAGE_CRITICAL` env branch in `vite.config.mts`, and the separate CI
gate step.

## Notes

- The normal coverage report now includes the critical litegraph
folders, so the Codecov `unit` flag and the coverage Slack baseline will
show a one-time shift.
- Filtered local runs (`pnpm test:coverage <file>`) fail the gate since
most critical files are uncovered; a full `pnpm test:coverage`
reproduces CI exactly.

Validation:
- `pnpm typecheck`, `pnpm exec eslint vite.config.mts`, `pnpm
format:check`, `pnpm knip` (pre-push)
- Smoke: `pnpm vitest run --coverage src/utils/colorUtil.test.ts` —
tests pass, then the gate fails all four metrics against the critical
bucket and exits 1, confirming enforcement happens inside the single run
- Full-suite gate numbers should be confirmed in CI
2026-07-03 05:43:05 +00:00
Benjamin Lu
df5f5b3367 feat: identify auth users to Syft (#13311)
## Summary

Identifies Cloud auth users to Syft via the required `identify(email, {
source })` handoff so Syft enrichment reliably attaches to signup/login
users instead of relying only on GTM page-load capture.

## Changes

- **What**: Adds a cloud-only Syft telemetry provider that reads
`syftdata_source_id` from `remoteConfig`, lazy-loads the Syft SDK
(reusing an already-loaded GTM Syft client when present), and calls
`identify` with `source: 'signup'` or `source: 'login'` on auth and on
session restore. `trackUserLoggedIn()` dedupes against the email already
handled by `trackAuth()` so a fresh login is not identified twice.
- **Dependencies**: None.

## Review Focus

- Preserves the FE-945 startup-blocker fix: the constructor reads only
the `remoteConfig` ref (a plain reactive ref, not Pinia) and never
touches current-user state; the user-email lookup happens only in
`trackUserLoggedIn()`, after app/auth setup. The source id is present at
construction because `main.ts` awaits the anonymous
`refreshRemoteConfig` before `initTelemetry`, and a later authenticated
refresh is picked up reactively on the next `ensureSyftClient()` call.
- SDK loader is idempotent with the current GTM Syft tag during rollout
(one script per `SYFT_SRC`). On load failure it clears its own stub —
guarded by an identity check so it never evicts a real client another
loader installed — letting a subsequent call retry. Long-term cleanup is
to keep one loader path per surface.
- Acceptance should include staging Network verification for
`https://e2.sy-d.io/events` payloads containing an `identify` event for
Google, GitHub, and email auth.

Linear: GTM-168
2026-07-03 05:10:52 +00:00
imick-io
156f2f59b7 feat(website): swap nav featured card to Comfy MCP (#13388)
## Summary

Repurpose the Products dropdown featured card to promote Comfy MCP.

## Changes

- **What**: Update the nav featured card title ("NEW: COMFY MCP"), alt
text, image asset (`mcp-card.webp`), and CTA ("GET STARTED") in
`mainNavigation.ts`; route the CTA to the localized `/mcp` page via
`routes.mcp`. All copy is i18n'd (en + zh-CN) in `translations.ts`,
adding a reusable `cta.getStarted` key.

## Review Focus

- CTA uses a new reusable `cta.getStarted` key rather than the
section-scoped `mcp.setup.label`, and routes to the internal
`routes.mcp` so non-en locales resolve to `/{locale}/mcp`.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 05:01:55 +00:00
nav-tej
d855466fdf fix(website): cap contact intro text width and space it from the form (#13420)
*PR Created by the Glary-Bot Agent*

---

## Summary

On `comfy.org/contact` the intro copy ("Create powerful workflows, scale
without limits." + description) ran right up against the HubSpot form
fields on desktop. The two `lg:w-1/2` columns had no gap between them
and the left column had no max-width, so long description text extended
almost to the form's edge.

- Add `lg:gap-16` between the two columns in `FormSection.vue`, matching
the pattern already used by `common/ContentSection.vue` and
`legal/LegalContentSection.vue`.
- Wrap the intro text block (badge + heading + description) in an
`lg:max-w-xl` container so the copy no longer stretches into the gap.
The illustration below keeps its full-column bleed via the existing
`lg:-ml-20`.
- Mobile (`<lg`) layout is unchanged — all new classes are
`lg:`-prefixed.

## Verification

Screenshots taken via Playwright against the local `apps/website` dev
server:

- Desktop (1512×900) — intro text now caps at a comfortable line length
with a clear gap to the form.
- 1024px — still works at the `lg:` breakpoint.
- 375px mobile — visually identical to before.

Also ran `pnpm format` and `pnpm --filter @comfyorg/website typecheck`
(0 errors). Three pre-existing
`better-tailwindcss/enforce-consistent-class-order` lint warnings on
this file exist on `main` and were left untouched.

- Fixes contact-page layout complaint from #website-and-docs (July 2)

## Screenshots

![Contact page after fix at 1512px — intro text capped with clear gap to
form](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5f8bf9cb18d1cd3fea28126c5aa832b0c655c1ecef2398b16fa50d81520df3fd/pr-images/1783049233475-b5932d36-9087-4689-a7ea-925bad2f09ff.png)

![Contact page after fix at 1024px
viewport](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5f8bf9cb18d1cd3fea28126c5aa832b0c655c1ecef2398b16fa50d81520df3fd/pr-images/1783049234208-8ef2cdac-b929-4c4d-b60a-e794f989fd76.png)

![Contact page after fix at 375px mobile viewport — layout
unchanged](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/5f8bf9cb18d1cd3fea28126c5aa832b0c655c1ecef2398b16fa50d81520df3fd/pr-images/1783049234909-c502d978-2586-4ce7-b54a-d96fc759d306.png)

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
2026-07-03 03:51:31 +00:00
AustinMroz
9d5719871a Compact vue nodes (#12886)
Updates vue nodes to be compact. 

This PR does modify the sizing of the asset dropdown (as used on nodes
like "Load Image"). There are outstanding concerns about the visibility
of the upload button and ongoing work to address this.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/5c866d6f-d83e-40e1-9d87-17b990d94e04"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/2a809e90-13aa-4f95-8b73-3f20b02fd9a1"
/>|

Subsumes #12678

---------

Co-authored-by: Alex <alex@Alexs-MacBook-Pro.local>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-03 02:31:41 +00:00
ShihChi Huang
7610a61250 test: cover queue display formatting (#13089)
## Summary

Add direct tests for queue job display formatting.

Base: `main`

## Changes

- Covers state icons, pending/initializing labels, running progress,
completed local/cloud output, fallback completed titles, and failed
display.

## Test Results

| | before | after |
| -- | -- | -- |
| `pnpm test:unit src/utils/queueDisplay.test.ts --run` | no direct
queue display test file |  13 passed |

## Coverage

Superseded by #13332. Historical pre-#13313 branch coverage:
`src/utils/queueDisplay.ts` 22.72% -> 79.54% (+56.82%); overall branches
52.95% -> 53.03% (+0.08%).

Codecov project coverage is intentionally omitted here because it is not
the branch-ratchet metric.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Test-only change; no runtime or production code modified.
> 
> **Overview**
> Adds **`src/utils/queueDisplay.test.ts`**, a Vitest suite that
exercises **`iconForJobState`** and **`buildJobDisplay`** from
`queueDisplay.ts` without touching UI or production logic.
> 
> Tests use small **`createJob` / `createTask` / `createCtx`** helpers
with a stub **`t`** and clock formatter so expectations assert i18n keys
and formatted values. Coverage includes pending “added to queue” hint,
queued/initializing labels, active vs inactive running progress,
completed local preview vs cloud duration, completed title fallback, and
failed rows with **`showClear`** behavior.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
6260c101e5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:39:24 +00:00
ShihChi Huang
47c8b09ebf test: 2/x cover fuse search ranking (#13087)
## Summary

Add direct tests for `fuseUtil` search ranking and filter behavior.


## Changes

- Covers ranking tiers, deprecated penalties, post-processing, empty
queries, auxiliary score comparison, and filter wildcard/comma matching.

## Test Results

- `pnpm test:unit src/utils/fuseUtil.test.ts`: 7 passed.
- `pnpm typecheck`: passed.
- `pnpm test:coverage`: 876 test files passed; 11,759 passed / 8
skipped.

## Coverage

Superseded by #13332. Historical pre-#13313 branch coverage:
`src/utils/fuseUtil.ts` 81.48% -> 92.59% (+11.11%); overall branches
52.93% -> 52.95% (+0.02%).

Codecov project coverage is intentionally omitted here because it is not
the branch-ratchet metric.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> <sup>[Cursor Bugbot](https://cursor.com/bugbot) is generating a
summary for commit 8bf748d1a4. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
Co-authored-by: Alexis Rolland <alexisrolland@hotmail.com>
2026-07-02 22:30:50 +00:00
ShihChi Huang
65b4c53bcb ci: skip website report deploy for fork PRs (#13344)
## Summary

Skip the website e2e report/deploy step for fork PRs, which lack the
deploy secrets and otherwise fail the job.

## Changes

- **What**: Guard the report/deploy step's `if:` in
`ci-website-e2e.yaml` so it runs only when the event is not a fork pull
request.
- **Breaking**: none. CI-config only.

## Review Focus

CI-config only — no test or coverage change. Confirms fork PRs no longer
fail on the deploy step.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> CI workflow condition only; no application or test logic changes.
> 
> **Overview**
> **Website E2E CI** no longer runs the **Deploy report to Cloudflare**
step on pull requests from forks.
> 
> The step’s `if:` still requires `always()` and `!cancelled()`, and now
also requires either a non–pull-request event or a PR whose head repo is
**not** a fork. Playwright tests and artifact upload are unchanged; only
the wrangler deploy (which needs `CLOUDFLARE_*` secrets) is skipped for
fork PRs so those runs don’t fail when secrets aren’t available.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
02a4ab0769. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:30:03 +00:00
ShihChi Huang
15b31d69ea ci: skip secret-backed CI deploys for fork PRs (#13291)
## Summary

Skip secret-backed CI deploy and dispatch work for fork PRs so missing
repo secrets do not fail otherwise valid checks.

## Changes

- **What**: Guard Website E2E report deploy, Vercel website preview
deploy, cloud build dispatch, cloud cleanup dispatch, and Storybook
Chromatic deploy so PR paths only run for same-repo PRs.
- **Dependencies**: None

## Why

Fork `pull_request` runs do not receive repository secrets. Several CI
jobs already separated normal validation from privileged follow-up work,
but some deploy or dispatch steps could still run on fork PRs and fail
only because their secret-backed integration token was empty.

The existing Website E2E fork guard only protected the PR comment job.
It did not protect the earlier Cloudflare report deploy step inside
`website-e2e`, which uses `CLOUDFLARE_API_TOKEN` and
`CLOUDFLARE_ACCOUNT_ID`.

The same failure mode existed in these CI jobs:

- `ci-vercel-website-preview.yaml`: preview deploy uses Vercel and
website API secrets.
- `cloud-dispatch-build.yaml`: preview dispatch uses
`CLOUD_DISPATCH_TOKEN` to call `Comfy-Org/cloud`.
- `cloud-dispatch-cleanup.yaml`: preview cleanup dispatch uses
`CLOUD_DISPATCH_TOKEN`.
- `ci-tests-storybook.yaml`: Chromatic deploy uses
`CHROMATIC_PROJECT_TOKEN`.

`ci-website-build.yaml` was left unchanged. Its Ashby and Cloud nodes
integrations intentionally fall back to committed snapshots when secrets
are missing for preview/local builds, so it is not the same class of
fork-secret failure.

## Review Focus

Confirm fork PRs still run the unprivileged validation/build paths,
while same-repo PRs and non-PR events keep the existing deploy or
dispatch behavior.

## Validation PRs

Both validation PRs compare against `main`.

- Fork PR from `shihchi`:
[#13309](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13309)
- Same-repo PR from `origin`:
[#13310](https://github.com/Comfy-Org/ComfyUI_frontend/pull/13310)

| Workflow | Guarded job or step | Fork #13309 | Same-repo #13310 |
| --- | --- | --- | --- |
| CI: Website E2E | `Upload test report` | success  | success  |
| CI: Website E2E | `Deploy report to Cloudflare` | skipped  | success
 |
| CI: Vercel Website Preview | `deploy-preview` | skipped  | success 
|
| Cloud Frontend Build Dispatch | `dispatch` | skipped  | success  |
| CI: Tests Storybook | `chromatic-deployment` | skipped  | success  |

Expected result: fork PRs still keep the useful validation artifact
path, but skip secret-backed deploy and dispatch work. Same-repo PRs
keep the privileged behavior.

## Screenshots (if applicable)

N/A, CI-only.

Created by Codex

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Workflow `if` condition changes only; no application code. Same-repo
PR behavior is unchanged when secrets are available.
> 
> **Overview**
> Adds **`github.event.pull_request.head.repo.fork == false`** guards so
fork PRs no longer run steps that need repo secrets or trigger external
deploys.
> 
> **Website E2E** — the Cloudflare Playwright report deploy step now
runs only on non-PR events or same-repo PRs, so fork runs can still pass
tests and upload artifacts without failing on missing `CLOUDFLARE_*`
secrets.
> 
> **Vercel website preview** — the preview deploy job is skipped
entirely for fork PRs (Vercel tokens).
> 
> **Storybook Chromatic** — Chromatic deployment on `version-bump-*` PRs
is limited to non-fork PRs (`CHROMATIC_PROJECT_TOKEN`).
> 
> **Cloud dispatch** — build and cleanup dispatches to the cloud repo
for preview labels no longer run for fork PRs, aligning with the
existing fork-guard comment in those workflows.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
027aabc9e3. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: huang47 <157390+huang47@users.noreply.github.com>
2026-07-02 22:29:47 +00:00
Benjamin Lu
471236e08d feat: track subscription cancellation intent and resubscribe clicks (#13368)
## Summary

Instruments the churn funnel: cancellation intent, attempt, abandonment,
and request failure, plus resubscribe clicks — all client-observed from
existing request/response flows, no watchers or polling added. Covers
both billing paths: the mainline (`/customers/*` + Stripe portal) path
via the "Manage subscription" click, and the workspace path via its
in-app cancel dialog.

## Changes

- **What**:
- New events: `app:subscription_cancel_flow_opened` / `_confirmed` /
`_abandoned` / `_failed` and `app:resubscribe_button_clicked`, via
`trackSubscriptionCancellation(stage, metadata)` and
`trackResubscribeClicked` (registry, PostHog, host sink)
  - All cancellation events carry a `source` discriminator:
- `manage_subscription_button` — the mainline path. Legacy users can
only cancel inside the Stripe billing portal, and in-app UI already
covers plan changes, so this click is the closest observable
cancel-intent signal for ~all production users. Only `flow_opened` fires
here (everything past the click happens in Stripe's UI). Probable, not
certain, intent — the portal also serves card updates/invoices.
- `cancel_plan_menu` — the workspace in-app dialog (allowlist-gated
pilot): `flow_opened` on mount, `confirmed` before the API call (failed
attempts still register), `failed` with the error message, `abandoned`
on "Keep subscription"/close. Successful cancels close via a different
path and never emit `abandoned`.
- Metadata carries `current_tier`, billing `cycle`, and (dialog path)
the `end_date` shown to the user
- Resubscribe clicks tracked at both call sites with `source`:
`pricing_dialog` (`useSubscriptionCheckout`, also carrying the dialog's
`payment_intent_source` from #13363) and `settings_billing_panel`
(`useResubscribe`)
- Not instrumented on purpose: the workspace "Manage billing" button and
the "Invoice history" footer link (portal opens without cancel
connotation)

## Review Focus

- Deliberately **no** client-side "cancel succeeded" event: outcome
truth is server-side. Mainline already has it
(`billing:subscription_deleted` from the Stripe webhook in comfy-api);
the workspace path needs a `subscription_cancelled` billing event type
(separate cloud-repo change). The legacy
`useSubscriptionCancellationWatcher` poller emits an undercounted
`app:monthly_subscription_cancelled`; analysis should prefer the server
event.
- `confirmed` fires before the request; growth can join
`flow_opened`/`confirmed` → server-side cancelled events by user +
timestamp.
2026-07-02 14:51:12 -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
6455a49f58 test(appMode): drop ErrorOverlay assertion from LinearView
ErrorOverlay was removed from LinearView in #12557 (replaced by the app
mode run validation warning); update the merged test to match.

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

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

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

View File

@@ -1,86 +0,0 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"if": "Bash(tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(vue-tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running vue-tsc directly.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via npx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of running tsc via pnpx.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpm exec tsc *)",
"command": "echo 'Use `pnpm typecheck` instead of `pnpm exec tsc`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of npx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx vitest *)",
"command": "echo 'Use `pnpm test:unit` (or `pnpm test:unit <path>`) instead of pnpx vitest.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of npx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx eslint *)",
"command": "echo 'Use `pnpm lint` or `pnpm lint:fix` instead of pnpx eslint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx prettier *)",
"command": "echo 'This project uses oxfmt, not prettier. Use `pnpm format` or `pnpm format:check`.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx oxlint *)",
"command": "echo 'Use `pnpm oxlint` instead of npx oxlint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx stylelint *)",
"command": "echo 'Use `pnpm stylelint` instead of npx stylelint.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(npx knip *)",
"command": "echo 'Use `pnpm knip` instead of npx knip.' >&2 && exit 2"
},
{
"type": "command",
"if": "Bash(pnpx knip *)",
"command": "echo 'Use `pnpm knip` instead of pnpx knip.' >&2 && exit 2"
}
]
}
]
}
}

View File

@@ -134,6 +134,27 @@ jobs:
fi
echo '✅ No Customer.io references found'
- name: Scan dist for Syft telemetry references
run: |
set -euo pipefail
echo '🔍 Scanning for Syft references...'
if rg --no-ignore -n \
-g '*.html' \
-g '*.js' \
-e '(?i)syft' \
-e '(?i)sy-d\.io' \
dist; then
echo '❌ ERROR: Syft references found in dist assets!'
echo 'Syft must be properly tree-shaken from OSS builds.'
echo ''
echo 'To fix this:'
echo '1. Use the TelemetryProvider pattern (see src/platform/telemetry/)'
echo '2. Call telemetry via useTelemetry() hook'
echo '3. Use conditional dynamic imports behind isCloud checks'
exit 1
fi
echo '✅ No Syft references found'
- name: Scan dist for Cloudflare Turnstile sitekey references
run: |
set -euo pipefail

View File

@@ -95,6 +95,7 @@ jobs:
if: |
github.event_name == 'workflow_dispatch'
|| (github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.fork == false
&& startsWith(github.head_ref, 'version-bump-')
&& (needs.changes.outputs.storybook-changes == 'true'
|| needs.changes.outputs.app-frontend-changes == 'true'

View File

@@ -55,6 +55,3 @@ jobs:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Enforce critical coverage gate
run: pnpm test:coverage:critical

View File

@@ -30,7 +30,7 @@ concurrency:
jobs:
deploy-preview:
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false
runs-on: ubuntu-latest
permissions:
contents: read

View File

@@ -67,7 +67,15 @@ jobs:
- name: Deploy report to Cloudflare
id: deploy
if: always() && !cancelled()
if: >-
${{
always() &&
!cancelled() &&
(
github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.fork == false
)
}}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

View File

@@ -32,12 +32,13 @@ jobs:
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
(github.event_name != 'pull_request' ||
(github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))
(github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'labeled' &&
contains(fromJSON('["preview","preview-cpu","preview-gpu"]'), github.event.label.name)) ||
(github.event.action == 'synchronize' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||
contains(github.event.pull_request.labels.*.name, 'preview-gpu'))))))
runs-on: ubuntu-latest
steps:
- name: Build client payload

View File

@@ -21,6 +21,7 @@ jobs:
# - Preview label specifically removed
if: >
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
github.event.pull_request.head.repo.fork == false &&
((github.event.action == 'closed' &&
(contains(github.event.pull_request.labels.*.name, 'preview') ||
contains(github.event.pull_request.labels.*.name, 'preview-cpu') ||

View File

@@ -56,7 +56,7 @@ const columnClass: Record<ColumnCount, string> = {
<template>
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
<SectionHeader :label="eyebrow" align="start">
<SectionHeader max-width="xl" :label="eyebrow" align="start">
{{ heading }}
<template v-if="subtitle" #subtitle>
<p class="mt-4 max-w-xl text-sm text-smoke-700 lg:text-base">

View File

@@ -33,36 +33,41 @@ useHeroAnimation({
</script>
<template>
<section ref="sectionRef" class="px-4 py-20 lg:flex lg:px-20 lg:py-24">
<section
ref="sectionRef"
class="px-4 py-20 lg:flex lg:gap-16 lg:px-20 lg:py-24"
>
<!-- Left column: intro + image -->
<div class="lg:w-1/2">
<SectionLabel ref="badgeRef">
{{ t(tk('badge'), locale) }}
</SectionLabel>
<div class="lg:max-w-xl">
<SectionLabel ref="badgeRef">
{{ t(tk('badge'), locale) }}
</SectionLabel>
<h1
ref="headingRef"
class="text-primary-comfy-canvas mt-4 text-3xl font-light whitespace-pre-line lg:text-5xl"
>
{{ t(tk('heading'), locale) }}
</h1>
<h1
ref="headingRef"
class="mt-4 text-3xl font-light whitespace-pre-line text-primary-comfy-canvas lg:text-5xl"
>
{{ t(tk('heading'), locale) }}
</h1>
<div ref="descRef">
<p class="text-primary-comfy-canvas mt-4 text-sm">
{{ t(tk('description'), locale) }}
</p>
<div ref="descRef">
<p class="mt-4 text-sm text-primary-comfy-canvas">
{{ t(tk('description'), locale) }}
</p>
<p class="text-primary-comfy-canvas mt-4 text-sm">
{{ t(tk('supportLink'), locale) }}
<a
href="https://docs.comfy.org/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow underline"
>
{{ t(tk('supportLinkCta'), locale) }}
</a>
</p>
<p class="mt-4 text-sm text-primary-comfy-canvas">
{{ t(tk('supportLink'), locale) }}
<a
href="https://docs.comfy.org/"
target="_blank"
rel="noopener noreferrer"
class="text-primary-comfy-yellow underline"
>
{{ t(tk('supportLinkCta'), locale) }}
</a>
</p>
</div>
</div>
<div ref="imageRef" class="mt-8 overflow-hidden rounded-2xl lg:-ml-20">

View File

@@ -40,13 +40,13 @@ export function getMainNavigation(locale: Locale): NavItem[] {
{
label: t('nav.products', locale),
featured: {
imageSrc: 'https://media.comfy.org/website/nav/featured-model-card.jpg',
imageSrc: 'https://media.comfy.org/website/nav/mcp-card.webp',
imageAlt: t('nav.featuredProductsAlt', locale),
title: t('nav.featuredProductsTitle', locale),
cta: {
label: t('cta.tryWorkflow', locale),
label: t('cta.getStarted', locale),
ariaLabel: t('nav.featuredProductsCtaAria', locale),
href: 'https://comfy.org/workflows/api_seedance2_0_r2v-64f4db9e3e33/'
href: routes.mcp
}
},
columns: [

View File

@@ -26,6 +26,10 @@ const translations = {
en: 'Try Workflow',
'zh-CN': '试用工作流'
},
'cta.getStarted': {
en: 'GET STARTED',
'zh-CN': '快速开始'
},
'cta.watchNow': {
en: 'Watch Now',
'zh-CN': '立即观看'
@@ -2196,16 +2200,16 @@ const translations = {
// Featured dropdown cards — keys are keyed by parent nav item, not card content,
// so the copy can be swapped without renaming the key.
'nav.featuredProductsTitle': {
en: 'New Release: Seedance 2.0',
'zh-CN': '全新发布:Seedance 2.0'
en: 'NEW: COMFY MCP',
'zh-CN': '全新发布:Comfy MCP'
},
'nav.featuredProductsAlt': {
en: 'Seedance 2.0 release feature image',
'zh-CN': 'Seedance 2.0 发布精选图片'
en: 'Comfy MCP feature image',
'zh-CN': 'Comfy MCP 精选图片'
},
'nav.featuredProductsCtaAria': {
en: 'Try the Seedance 2.0 workflow',
'zh-CN': '试用 Seedance 2.0 工作流'
en: 'Get started with Comfy MCP',
'zh-CN': '开始使用 Comfy MCP'
},
'nav.featuredCommunityTitle': {
en: 'Sky Replacement',

View File

@@ -28,6 +28,7 @@ import {
ModelLibrarySidebarTab,
NodeLibrarySidebarTab,
NodeLibrarySidebarTabV2,
SidebarTab,
WorkflowsSidebarTab
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
@@ -70,6 +71,7 @@ class ComfyPropertiesPanel {
}
class ComfyMenu {
private _appsTab: SidebarTab | null = null
private _assetsTab: AssetsSidebarTab | null = null
private _modelLibraryTab: ModelLibrarySidebarTab | null = null
private _nodeLibraryTab: NodeLibrarySidebarTab | null = null
@@ -104,6 +106,11 @@ class ComfyMenu {
return this._nodeLibraryTabV2
}
get appsTab() {
this._appsTab ??= new SidebarTab(this.page, 'apps')
return this._appsTab
}
get assetsTab() {
this._assetsTab ??= new AssetsSidebarTab(this.page)
return this._assetsTab
@@ -537,7 +544,6 @@ export const comfyPageFixture = base.extend<{
'Comfy.TutorialCompleted': true,
'Comfy.Queue.MaxHistoryItems': 64,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize,
'Comfy.VueNodes.AutoScaleLayout': false,
// Disable toast warning about version compatibility, as they may or
// may not appear - depending on upstream ComfyUI dependencies
'Comfy.VersionCompatibility.DisableWarnings': true,

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,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 single instance teleports between the subgraph breadcrumb (graph mode)
* and the app-mode center panel 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 teleport 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
@@ -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
@@ -185,10 +188,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

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

View File

@@ -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,118 @@ test.describe('App mode usage', () => {
await expect.poll(() => fileComboWidget.getValue()).toBe(targetImage)
})
test('Shares the graph side toolbar, filtered to assets + apps', async ({
comfyPage
}) => {
const { sideToolbar, nodeLibraryTab, assetsTab, appsTab } = comfyPage.menu
await test.step('Graph mode shows the full toolbar', async () => {
await expect(sideToolbar).toBeVisible()
await expect(nodeLibraryTab.tabButton).toBeVisible()
})
await test.step('App mode reuses it with only assets + apps', async () => {
await comfyPage.appMode.enterAppModeWithInputs([['3', 'seed']])
await expect(comfyPage.appMode.centerPanel).toBeVisible()
await expect(sideToolbar).toBeVisible()
await expect(assetsTab.tabButton).toBeVisible()
await expect(appsTab.tabButton).toBeVisible()
await expect(nodeLibraryTab.tabButton).toBeHidden()
})
})
test('Workflow actions menu keeps the same position across graph/app mode', async ({
comfyPage,
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 toggle's teleport host re-mounts and the toggle
// re-appears.
await expect(toggle).toBeVisible()
await expect(comfyPage.appMode.centerPanel).toBeHidden()
})
test('Mode toggle survives a sidebar tab remounting the app panel', async ({
comfyPage
}) => {
const toggle = comfyPage.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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -46,6 +46,10 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
{ tag: ['@smoke', '@screenshot'] },
async ({ comfyPage, maskEditor }) => {
const { nodeId } = await maskEditor.loadImageOnNode()
// 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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -691,7 +691,8 @@ test(
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
await comfyPage.canvas.hover({ position: emptySlotPos })
await comfyPage.page.mouse.down()
await stepsSlot.hover()
const { width, height } = (await stepsSlot.boundingBox())!
await stepsSlot.hover({ position: { x: (width * 3) / 4, y: height / 2 } })
await expect.poll(hasSnap).toBe(true)
await comfyPage.page.mouse.up()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1238,7 +1238,7 @@ test(
{ tag: '@vue-nodes' },
async ({ comfyMouse, comfyPage }) => {
async function performDisconnect(slot: Locator, isFast: boolean) {
await comfyMouse.dragElementBy(slot, { x: isFast ? -25 : -80 })
await comfyMouse.dragElementBy(slot, { x: isFast ? -30 : -80 })
if (!isFast) {
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible()
@@ -1251,7 +1251,7 @@ test(
const ksamplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
const ksampler = new VueNodeFixture(ksamplerLocator)
await comfyMouse.dragElementBy(ksamplerLocator, { x: 100 })
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
await test.step('Disconnection with normal links', async () => {
await performDisconnect(ksampler.getSlot('model'), true)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -234,7 +234,8 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
await comfyPage.page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write'])
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.nodeOps.clearGraph()
await comfyPage.searchBoxV2.addNode('Load Image')
await comfyPage.vueNodes.waitForNodes(1)
await comfyPage.page
.locator('[data-node-id] img')

View File

@@ -14,7 +14,8 @@ const wstest = mergeTests(test, webSocketFixture)
test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
async function loadImageOnNode(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.nodeOps.clearGraph()
await comfyPage.searchBoxV2.addNode('Load Image')
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -12,14 +12,14 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
const getHeaderPos = async (
comfyPage: ComfyPage,
title: string
): Promise<{ x: number; y: number; width: number; height: number }> => {
): Promise<{ x: number; y: number }> => {
const box = await comfyPage.vueNodes
.getNodeByTitle(title)
.getByTestId('node-title')
.first()
.boundingBox()
if (!box) throw new Error(`${title} header not found`)
return box
return { x: box.x + box.width / 2, y: box.y + box.height / 2 }
}
const getLoadCheckpointHeaderPos = async (comfyPage: ComfyPage) =>
@@ -84,29 +84,27 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await comfyPage.idleFrames(2)
}
test('should allow moving nodes by dragging', async ({ comfyPage }) => {
const loadCheckpointHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await comfyPage.canvasOps.dragAndDrop(loadCheckpointHeaderPos, {
x: 256,
y: 256
})
test('should allow moving nodes by dragging', async ({
comfyPage,
comfyMouse
}) => {
const initialHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
await comfyMouse.dragElementBy(node.header, { x: 100, y: 100 })
const newHeaderPos = await getLoadCheckpointHeaderPos(comfyPage)
await expectPosChanged(loadCheckpointHeaderPos, newHeaderPos)
await expectPosChanged(initialHeaderPos, newHeaderPos)
})
test('should not move node when pointer moves less than drag threshold', async ({
comfyPage
comfyPage,
comfyMouse
}) => {
const headerPos = await getLoadCheckpointHeaderPos(comfyPage)
// Move only 2px — below the 3px drag threshold in useNodePointerInteractions
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(headerPos.x + 2, headerPos.y + 1, {
steps: 5
})
await comfyPage.page.mouse.up()
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
await comfyMouse.dragElementBy(node.header, { x: 2, y: 1 })
await comfyPage.nextFrame()
const afterPos = await getLoadCheckpointHeaderPos(comfyPage)
@@ -295,14 +293,12 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
await expect(comfyPage.vueNodes.selectedNodes).toHaveCount(3)
// Re-fetch drag source after clicks in case the header reflowed.
const dragSrc = await getHeaderPos(comfyPage, 'Load Checkpoint')
const centerX = dragSrc.x + dragSrc.width / 2
const centerY = dragSrc.y + dragSrc.height / 2
const headerPos = await getHeaderPos(comfyPage, 'Load Checkpoint')
await comfyPage.page.mouse.move(centerX, centerY)
await comfyPage.page.mouse.move(headerPos.x, headerPos.y)
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(centerX + dx, centerY + dy, {
await comfyPage.page.mouse.move(headerPos.x + dx, headerPos.y + dy, {
steps: 20
})
await comfyPage.page.mouse.up()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -42,7 +42,10 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
await expect(pinIndicator2).toBeHidden()
})
test('should not allow dragging pinned nodes', async ({ comfyPage }) => {
test('should not allow dragging pinned nodes', async ({
comfyMouse,
comfyPage
}) => {
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
await checkpointNodeHeader.click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
@@ -50,10 +53,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
// Try to drag the node
const headerPos = await checkpointNodeHeader.boundingBox()
if (!headerPos) throw new Error('Failed to get header position')
await comfyPage.canvasOps.dragAndDrop(
{ x: headerPos.x, y: headerPos.y },
{ x: headerPos.x + 256, y: headerPos.y + 256 }
)
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
// Verify the node is not dragged (same position before and after click-and-drag)
await expect
@@ -64,11 +64,7 @@ test.describe('Vue Node Pin', { tag: '@vue-nodes' }, () => {
await checkpointNodeHeader.click()
await comfyPage.page.keyboard.press(PIN_HOTKEY)
// Try to drag the node again
await comfyPage.canvasOps.dragAndDrop(
{ x: headerPos.x, y: headerPos.y },
{ x: headerPos.x + 256, y: headerPos.y + 256 }
)
await comfyMouse.dragElementBy(checkpointNodeHeader, { x: 256, y: 256 })
// Verify the node is dragged
await expect

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -5,12 +5,7 @@ import {
test.describe('Widget copy button', { tag: ['@ui', '@vue-nodes'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
// Add a PreviewAny node which has a read-only textarea with a copy button
await comfyPage.page.evaluate(() => {
const node = window.LiteGraph!.createNode('PreviewAny')
window.app!.graph.add(node)
})
await comfyPage.searchBoxV2.addNode('Preview as Text')
await comfyPage.vueNodes.waitForNodes()
})

25
global.d.ts vendored
View File

@@ -41,6 +41,29 @@ interface GtagFunction {
(...args: unknown[]): void
}
type SyftDataTraits = Record<string, string | number | null | undefined>
interface SyftDataPendingFetch {
args: unknown[]
resolve: (value: unknown) => void
reject: (reason?: unknown) => void
}
interface SyftDataClient {
identify(email: string, traits?: SyftDataTraits): void
signup(email: string, traits?: SyftDataTraits): void
track(event: string, traits?: SyftDataTraits): void
page(...args: unknown[]): void
q?: unknown[][]
fi?: SyftDataPendingFetch[]
fetchID?: (...args: unknown[]) => Promise<unknown>
}
/** Installed by the Syft UMD instead of SyftDataClient when telemetry is opted out */
interface SyftDisabledClient {
enable: () => void
}
interface Window {
__CONFIG__: {
gtm_container_id?: string
@@ -78,6 +101,8 @@ interface Window {
}
dataLayer?: Array<Record<string, unknown>>
gtag?: GtagFunction
syft?: SyftDataClient | SyftDisabledClient
syftc?: { sourceId?: string; enabled?: boolean }
ire_o?: string
ire?: ImpactQueueFunction
rewardful?: RewardfulQueueFunction

View File

@@ -53,7 +53,6 @@
"test:browser:coverage": "cross-env COLLECT_COVERAGE=true pnpm test:browser",
"test:browser:local": "cross-env PLAYWRIGHT_LOCAL=1 PLAYWRIGHT_TEST_URL=http://localhost:5173 pnpm test:browser",
"test:coverage": "vitest run --coverage",
"test:coverage:critical": "cross-env COVERAGE_CRITICAL=true vitest run --coverage",
"test:unit": "vitest run",
"typecheck": "vue-tsc --noEmit",
"typecheck:browser": "vue-tsc --project browser_tests/tsconfig.json",

View File

@@ -197,7 +197,7 @@
--node-component-executing: var(--color-blue-500);
--node-component-header: var(--fg-color);
--node-component-header-icon: var(--color-ash-800);
--node-component-header-surface: var(--color-smoke-400);
--node-component-header-surface: var(--color-smoke-200);
--node-component-outline: var(--color-black);
--node-component-ring: rgb(from var(--color-smoke-500) r g b / 50%);
--node-component-slot-dot-outline-opacity-mult: 1;
@@ -343,7 +343,7 @@
--node-component-border-executing: var(--color-blue-500);
--node-component-border-selected: var(--color-charcoal-200);
--node-component-header-icon: var(--color-smoke-800);
--node-component-header-surface: var(--color-charcoal-800);
--node-component-header-surface: var(--color-charcoal-700);
--node-component-outline: var(--color-white);
--node-component-ring: rgb(var(--color-smoke-500) / 20%);
--node-component-slot-dot-outline-opacity: 10%;
@@ -727,14 +727,14 @@ body {
/* Shared markdown content styling for consistent rendering across components */
.comfy-markdown-content {
/* Typography */
font-size: 0.875rem; /* text-sm */
font-size: var(--comfy-textarea-font-size);
line-height: 1.6;
word-wrap: break-word;
}
/* Headings */
.comfy-markdown-content h1 {
font-size: 22px; /* text-[22px] */
font-size: calc(22 / 14 * var(--comfy-textarea-font-size));
font-weight: 700; /* font-bold */
margin-top: 2rem; /* mt-8 */
margin-bottom: 1rem; /* mb-4 */
@@ -745,7 +745,7 @@ body {
}
.comfy-markdown-content h2 {
font-size: 18px; /* text-[18px] */
font-size: calc(18 / 14 * var(--comfy-textarea-font-size));
font-weight: 700; /* font-bold */
margin-top: 2rem; /* mt-8 */
margin-bottom: 1rem; /* mb-4 */
@@ -756,7 +756,7 @@ body {
}
.comfy-markdown-content h3 {
font-size: 16px; /* text-[16px] */
font-size: calc(16 / 14 * var(--comfy-textarea-font-size));
font-weight: 700; /* font-bold */
margin-top: 2rem; /* mt-8 */
margin-bottom: 1rem; /* mb-4 */

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

@@ -22,7 +22,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { HideLayoutFieldKey, WidgetHeightKey } from '@/types/widgetTypes'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import { promptRenameWidget } from '@/utils/widgetUtil'
@@ -50,6 +50,7 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
)
provide(HideLayoutFieldKey, true)
provide(WidgetHeightKey, mobile ? 'h-10' : 'h-7')
const resolvedInputs = useResolvedSelectedInputs()
@@ -236,7 +237,7 @@ defineExpose({ handleDragDrop })
:node-data
:class="
cn(
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1 not-md:**:[.h-7]:h-10',
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1',
nodeData.hasErrors && 'ring-2 ring-node-stroke-error ring-inset'
)
"

View File

@@ -1,20 +1,26 @@
<template>
<div
ref="container"
class="flex h-7 rounded-lg bg-component-node-widget-background text-xs text-component-node-foreground"
:class="
cn(
'flex overflow-hidden rounded-md bg-component-node-widget-background text-xs text-component-node-foreground',
useWidgetHeight()
)
"
>
<slot name="background" />
<Button
v-if="!hideButtons"
:aria-label="t('g.decrement')"
data-testid="decrement"
class="aspect-8/7 h-full rounded-r-none hover:bg-base-foreground/20 disabled:opacity-30"
class="aspect-square h-full rounded-none p-0 hover:bg-component-node-widget-background-hovered disabled:opacity-30"
variant="muted-textonly"
size="unset"
:disabled="!canDecrement"
tabindex="-1"
@click="modelValue = clamp(modelValue - step)"
>
<i class="pi pi-minus" />
<i class="icon-[lucide--minus]" />
</Button>
<div class="relative my-0.25 min-w-[4ch] flex-1 py-1.5">
<input
@@ -24,7 +30,7 @@
:disabled
:class="
cn(
'absolute inset-0 truncate border-0 bg-transparent p-1 text-sm focus:outline-0'
'absolute inset-0 truncate border-0 bg-transparent p-1 text-xs focus:outline-0'
)
"
inputmode="decimal"
@@ -54,13 +60,14 @@
v-if="!hideButtons"
:aria-label="t('g.increment')"
data-testid="increment"
class="aspect-8/7 h-full rounded-l-none hover:bg-base-foreground/20 disabled:opacity-30"
class="aspect-square h-full rounded-none p-0 hover:bg-component-node-widget-background-hovered disabled:opacity-30"
variant="muted-textonly"
size="unset"
:disabled="!canIncrement"
tabindex="-1"
@click="modelValue = clamp(modelValue + step)"
>
<i class="pi pi-plus" />
<i class="icon-[lucide--plus]" />
</Button>
</div>
</template>
@@ -71,6 +78,7 @@ import { computed, ref, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useWidgetHeight } from '@/types/widgetTypes'
import { cn } from '@comfyorg/tailwind-utils'
const {

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
/** Truth: drives behavior and aria. Flips as soon as the mode changes. */
active: boolean
/** Frame-lagged mirror of {@link active}: drives the morph styling/order. */
displayActive: boolean
}
const { source, align = 'start' } = defineProps<{
source: string
@@ -23,46 +37,120 @@ 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). On mode
// switch the array reorders and TransitionGroup FLIP-animates the keyed nodes
// to their new spots.
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 carrying popup semantics, which makes
// this a stable, markup-derived way to find it.
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()
}
// Reimplements the two trigger-element behaviors of a stock DropdownMenuTrigger
// (which this component cannot use without breaking the FLIP morph): a click on
// the open menu's trigger toggles it closed instead of dismiss-then-reopen, and
// focus returns to the trigger on close unless the user interacted elsewhere.
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 +163,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

@@ -42,22 +42,34 @@ function withStrictMillisecondParser<T>(run: () => T): T {
}
const mockSubscription = vi.hoisted(() => ({
value: null as { endDate: string | null } | null
value: null as {
endDate: string | null
duration?: 'ANNUAL' | 'MONTHLY' | null
} | null
}))
const mockCancelSubscription = vi.hoisted(() => vi.fn())
const mockFetchStatus = vi.hoisted(() => vi.fn())
const mockCloseDialog = vi.hoisted(() => vi.fn())
const mockToastAdd = vi.hoisted(() => vi.fn())
const mockTier = vi.hoisted(() => ({ value: 'STANDARD' as string | null }))
const mockTrackCancellation = vi.hoisted(() => vi.fn())
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: vi.fn(() => ({
cancelSubscription: mockCancelSubscription,
fetchStatus: mockFetchStatus,
subscription: mockSubscription
subscription: mockSubscription,
tier: mockTier
}))
}))
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({
trackSubscriptionCancellation: mockTrackCancellation
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
closeDialog: mockCloseDialog
@@ -94,6 +106,95 @@ function renderComponent(props: { cancelAt?: string } = {}) {
describe('CancelSubscriptionDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockTier.value = 'STANDARD'
})
describe('cancellation telemetry', () => {
it('tracks flow_opened with tier and end date when the dialog mounts', () => {
mockSubscription.value = { endDate: '2026-08-01T00:00:00.000Z' }
renderComponent()
expect(mockTrackCancellation).toHaveBeenCalledWith('flow_opened', {
source: 'cancel_plan_menu',
current_tier: 'standard',
end_date: '2026-08-01T00:00:00.000Z'
})
})
it('tracks confirmed before the cancel request and no abandoned on success', async () => {
mockSubscription.value = null
mockCancelSubscription.mockResolvedValueOnce(undefined)
const { unmount } = renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() => expect(mockCloseDialog).toHaveBeenCalled())
unmount()
expect(mockTrackCancellation).toHaveBeenCalledWith(
'confirmed',
expect.objectContaining({ current_tier: 'standard' })
)
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
'abandoned',
expect.anything()
)
})
it('tracks confirmed and failed with message-carrying rejection values', async () => {
mockSubscription.value = null
mockCancelSubscription.mockRejectedValueOnce({ message: 'timed out' })
renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() =>
expect(mockTrackCancellation).toHaveBeenCalledWith(
'failed',
expect.objectContaining({ error_message: 'timed out' })
)
)
expect(mockTrackCancellation).toHaveBeenCalledWith(
'confirmed',
expect.anything()
)
})
it('tracks abandoned when the user keeps the subscription', async () => {
mockSubscription.value = null
const { unmount } = renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /keep subscription/i })
)
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'cancel-subscription'
})
unmount()
expect(mockTrackCancellation).toHaveBeenCalledWith(
'abandoned',
expect.objectContaining({ current_tier: 'standard' })
)
expect(mockCancelSubscription).not.toHaveBeenCalled()
})
it('tracks abandoned when the dialog is dismissed by the shell', () => {
mockSubscription.value = null
const { unmount } = renderComponent()
mockTrackCancellation.mockClear()
unmount()
expect(mockTrackCancellation).toHaveBeenCalledWith(
'abandoned',
expect.objectContaining({ current_tier: 'standard' })
)
})
})
describe('cancel flow', () => {
@@ -138,6 +239,35 @@ describe('CancelSubscriptionDialogContent', () => {
expect.objectContaining({ severity: 'success' })
)
})
it('does not track cancellation failure when status refresh fails after cancellation succeeds', async () => {
mockSubscription.value = null
mockCancelSubscription.mockResolvedValueOnce(undefined)
mockFetchStatus.mockRejectedValueOnce(new Error('Refresh failed'))
const { unmount } = renderComponent()
await userEvent.click(
screen.getByRole('button', { name: /^cancel subscription$/i })
)
await waitFor(() =>
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'success' })
)
)
expect(mockCloseDialog).toHaveBeenCalledWith({
key: 'cancel-subscription'
})
expect(
mockTrackCancellation.mock.calls.some(([stage]) => stage === 'failed')
).toBe(false)
unmount()
expect(mockTrackCancellation).not.toHaveBeenCalledWith(
'abandoned',
expect.anything()
)
})
})
describe('formattedEndDate fallbacks', () => {

View File

@@ -45,13 +45,16 @@
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { computed, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useTelemetry } from '@/platform/telemetry'
import type { SubscriptionCancellationMetadata } from '@/platform/telemetry/types'
import { useDialogStore } from '@/stores/dialogStore'
import { parseIsoDateSafe } from '@/utils/dateTimeUtil'
import { getErrorMessage } from '@/utils/errorUtil'
const props = defineProps<{
cancelAt?: string
@@ -60,9 +63,41 @@ const props = defineProps<{
const { t } = useI18n()
const dialogStore = useDialogStore()
const toast = useToast()
const { cancelSubscription, fetchStatus, subscription } = useBillingContext()
const { cancelSubscription, fetchStatus, subscription, tier } =
useBillingContext()
const telemetry = useTelemetry()
const isLoading = ref(false)
const didCancelSucceed = ref(false)
function cancellationMetadata(): SubscriptionCancellationMetadata {
const endDate = props.cancelAt ?? subscription.value?.endDate
return {
source: 'cancel_plan_menu' as const,
current_tier: tier.value?.toLowerCase(),
...(subscription.value?.duration
? {
cycle:
subscription.value.duration === 'ANNUAL'
? ('yearly' as const)
: ('monthly' as const)
}
: {}),
...(endDate ? { end_date: endDate } : {})
}
}
onMounted(() => {
telemetry?.trackSubscriptionCancellation(
'flow_opened',
cancellationMetadata()
)
})
onUnmounted(() => {
if (didCancelSucceed.value || isLoading.value) return
telemetry?.trackSubscriptionCancellation('abandoned', cancellationMetadata())
})
const formattedEndDate = computed(() => {
const date = parseIsoDateSafe(props.cancelAt ?? subscription.value?.endDate)
@@ -84,24 +119,37 @@ function onClose() {
}
async function onConfirmCancel() {
telemetry?.trackSubscriptionCancellation('confirmed', cancellationMetadata())
isLoading.value = true
try {
await cancelSubscription()
await fetchStatus()
dialogStore.closeDialog({ key: 'cancel-subscription' })
toast.add({
severity: 'success',
summary: t('subscription.cancelSuccess'),
life: 5000
})
} catch (error) {
const errorMessage = getErrorMessage(error)
telemetry?.trackSubscriptionCancellation('failed', {
...cancellationMetadata(),
error_message: errorMessage ?? String(error)
})
toast.add({
severity: 'error',
summary: t('subscription.cancelDialog.failed'),
detail: error instanceof Error ? error.message : t('g.unknownError')
detail: errorMessage ?? t('g.unknownError')
})
} finally {
isLoading.value = false
return
}
didCancelSucceed.value = true
try {
await fetchStatus()
} catch {
// Cancellation already succeeded; stale local subscription status should not report failure.
}
dialogStore.closeDialog({ key: 'cancel-subscription' })
toast.add({
severity: 'success',
summary: t('subscription.cancelSuccess'),
life: 5000
})
isLoading.value = false
}
</script>

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

@@ -31,7 +31,7 @@ import { getWidgetDefaultValue } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { HideLayoutFieldKey, WidgetHeightKey } from '@/types/widgetTypes'
import { GetNodeParentGroupKey } from '../shared'
import WidgetItem from './WidgetItem.vue'
@@ -135,6 +135,7 @@ watchDebounced(
onBeforeUnmount(() => draggableList.value?.dispose())
provide(HideLayoutFieldKey, true)
provide(WidgetHeightKey, 'h-7')
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()

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,18 +13,25 @@
{{ $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')}`"
button-icon="icon-[lucide--plus]"
:button-label="$t('linearMode.appModeToolbar.createApp')"
@action="createApp"
/>
</template>
</BaseWorkflowsSidebarTab>
@@ -33,16 +40,17 @@
<script setup lang="ts">
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import BaseWorkflowsSidebarTab from '@/components/sidebar/tabs/BaseWorkflowsSidebarTab.vue'
import { useAppMode } from '@/composables/useAppMode'
import Button from '@/components/ui/button/Button.vue'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useCommandStore } from '@/stores/commandStore'
const { isAppMode, setMode } = useAppMode()
const commandStore = useCommandStore()
function isAppWorkflow(workflow: ComfyWorkflow): boolean {
return workflow.suffix === 'app.json'
}
function enterAppMode() {
setMode('app')
function createApp() {
void commandStore.execute('Comfy.NewBlankWorkflow')
}
</script>

View File

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

View File

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

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