Compare commits

..

26 Commits

Author SHA1 Message Date
huang47
3562a66d1f feat: block disabled partner nodes from execution 2026-07-14 13:43:15 -07:00
huang47
68bea387e1 feat: surface disabled nodes as workflow errors 2026-07-14 13:43:15 -07:00
huang47
5322dd90a2 feat: hide disabled partner nodes from discovery 2026-07-14 13:43:15 -07:00
huang47
5c92d4b521 fix: harden partner node policy editor 2026-07-14 13:43:15 -07:00
huang47
f15710a43f feat: add gated partner node policy store 2026-07-14 13:43:15 -07:00
huang47
ac126f3c59 feat: add partner node policy API seam 2026-07-14 12:48:50 -07:00
huang47
957aafb9e8 fix: address workspace settings review feedback 2026-07-14 12:48:20 -07:00
huang47
3dfc5b6b52 fix: address follow-up review feedback 2026-07-14 12:12:11 -07:00
huang47
49130c5767 test: cover billing status lifecycle 2026-07-14 12:12:11 -07:00
huang47
8f5a9cc6bb fix: address workspace review feedback 2026-07-14 12:12:11 -07:00
huang47
d82d9f30d2 fix: preserve hidden partner node selections 2026-07-14 12:12:10 -07:00
huang47
300901c793 test: cover confirmed rollback ordering 2026-07-14 12:12:10 -07:00
huang47
ab646e7401 fix: address allowlist review feedback 2026-07-14 12:12:10 -07:00
huang47
58048b9f9f test: update mobile settings screenshot 2026-07-14 12:12:10 -07:00
huang47
9553b7a579 fix: prevent stale allowlist rollbacks 2026-07-14 12:12:10 -07:00
huang47
563eda5862 fix: harden allowlist governance controls 2026-07-14 12:12:10 -07:00
huang47
1c78ea091f test: cover allowlist governance edge cases 2026-07-14 12:12:10 -07:00
comfydesigner
7d6aa42019 feat: workspace Allowlist — partner nodes + models governance
Adds the Allowlist settings entry (owners/admins only) with two
sub-tabs sharing one search input. Partner nodes: the workspace's
partner-node list with per-row toggles, page-scoped select-all with
cross-page selection and a bulk toggle, Enable/Disable all acting on
the filtered set, and an auto-enable-new default, backed by the
partner-nodes workspace API with optimistic updates. Models: the same
governance surface for the models catalog — Model / Type / Last
modified with middle-truncated display names (HF names keep their
filename tail) — rendering its empty state until the model-allowlist
endpoint exists; the pagination and reset-to-page-1 search semantics
map onto the eventual limit/offset API, and Enable/Disable all needs a
server-side bulk-by-query op.
2026-07-14 12:12:10 -07:00
comfydesigner
6bddc2f9ae feat: billing lifecycle states across workspace settings
One status-banner slot (BillingStatusBanner) above every workspace
settings surface, rendering by priority: subscription paused / payment
declined / out of credits (amber triangle, action needed) and the plan-
ending notice (muted circle, secondary Reactivate — cancellation was
intentional). Adds the paused subscription status, the Credits tab
header with plan-aware inactive copy (team vs enterprise) wired to
resubscribe, and a member snapshot tile (top spenders / recent
activity from the members store, sized to the dialog via
useAutoPageSize; renders its empty states until the API carries
per-member usage).

CreditsTile: 24px total, Monthly/Yearly cycle bar (annual grants are
front-loaded), inverted Add credits when fully out, dims when paused,
and a frozen mode for lapsed plans. Credit iconography moves to
lucide-coins across credit surfaces.
2026-07-14 12:12:10 -07:00
comfydesigner
65c0d9fe54 feat: workspace Members settings panel + identity editing
Restructures workspace member management into its own settings entry:
a Members sidebar panel (table rows with role menu, last-activity and
credits-used columns rendering em-dash/0 until the API carries the
fields, pending-invite rows, Owner/Admin role labels), the workspace
settings header with inline rename (double-click or menu, save-on-blur,
Escape cancels, 30-char cap) and hover image-edit affordance, and the
member-limit flow (count-free copy, Invite enabled at cap surfacing a
request-seats dialog wired to the team-plan request form; seat cap 50
counting pending invites).

The existing combined Workspace panel stays registered so plan and
credit management remains reachable; a follow-up swaps it to the
dedicated Plan & Credits panel. Table primitives (nowrap cells,
divider rows) land here with their first consumer.
2026-07-14 12:12:10 -07:00
comfydesigner
8fdbd1161f feat: make dropdown menu modality configurable 2026-07-14 12:12:10 -07:00
comfydesigner
9caaa9e98b refactor: extract shared selection bar 2026-07-14 12:12:09 -07:00
Christian Byrne
1a98362984 fix(i18n): escape vue-i18n message syntax in generated node defs + preserve raw messages on compile errors (#13631)
## Summary

Defense-in-depth fix for the `Invalid linked format` crash: escape
vue-i18n message-syntax characters at node-def generation so the locale
bundle is always valid, **and** harden the runtime so any compile error
(custom nodes, already-shipped bundles) degrades to the raw message
instead of crashing the app.

This folds in the runtime guard from #13603 (@xmarre) — see credit below
— so the two layers land together.

## Changes

**Layer 1 — generation-time escaping (source data)**
- Add `escapeVueI18nMessageSyntax()` to
`packages/shared-frontend-utils/src/formatUtil.ts`. It escapes every
character vue-i18n's message compiler treats as syntax in text — `@ { }
| %` (verified against `@intlify/message-compiler`'s `readText`
tokenizer): `@` and unbalanced `{`/`}` throw, `|` mis-renders as plural
branches, `%` matters immediately before `{`. Each becomes a literal
interpolation `{'x'}` (the only escape vue-i18n supports); the set isn't
exported by the library so it's hardcoded with source/doc references.
Single-pass, applied once.
- Apply it in `scripts/collect-i18n-node-defs.ts` to the fields
**compiled** via `t()`/`st()`: `display_name`, `description`,
input/output `name`, widget labels, `dataTypes`, `nodeCategories`.
Tooltip fields go through `tm()`/`stRaw()` (no compile step) and are
intentionally left unescaped.
- Regression test (`src/locales/escapeNodeDefI18n.test.ts`) compiles the
escaped output for all five characters through real vue-i18n and asserts
it round-trips verbatim.

**Layer 2 — runtime fallback (consumer), folded in from #13603 by
@xmarre**
- `st()` now wraps `t(key)` in try/catch: a vue-i18n `SyntaxError` falls
back to the raw locale message (`tm()`) instead of crashing bootstrap.
Shared `rawTranslationOrFallback` helper reused by `stRaw()`. Tests in
`src/i18n.safeTranslation.test.ts` (isolated per-test, and covering the
non-`SyntaxError` rethrow branch).

**Tooling**
- Lint the collection scripts instead of ignoring them: import the
shared helper via the `@/utils/formatUtil` tsconfig alias (fixes
`import-x/no-relative-packages`; verified it resolves under Playwright)
and drop a stray progress `console.log`. No lint config changes and no
rules relaxed — the scripts are now genuinely linted (they were
previously excluded).

- **Breaking**: none.

## Review Focus

- **Root cause**: values read via `t()`/`st()` are compiled by vue-i18n,
which parses `@ { } | %` as message syntax; a malformed `@` throws
`Invalid linked format`. `app.ts` translates every node description at
startup, so one bad string aborts router load. Surfaced by the 1.47.7
locale sync (#13246), whose `ByteDanceSeedAudio` description contains
`@Audio1-3`.
- **The two layers compose**: escaped output compiles cleanly, so the
runtime catch never fires for generated strings; the catch is the safety
net for strings that bypass generation (custom/third-party nodes via
`mergeCustomNodesI18n`, and locale bundles already shipped in
1.47.x/1.48.x).
- **Compiled vs. raw boundary**: escaping is applied only to
compiler-consumed fields; leaving tooltips raw matches the
`stRaw()`/`tm()` work in #12469.
- **Translation propagation**: lobe-i18n's config prompt already keeps
`{...}` placeholders intact, so the escaped English propagates to all 13
locales.
- **Follow-up (out of scope)**: `scripts/collect-i18n-general.ts`
(commands/settings/menus) shares the same generation-time hazard and
could reuse the same helper later; Layer 2 already covers it at runtime.

## Credit

The runtime fallback (Layer 2) is @xmarre's work from #13603, folded in
here with authorship preserved on the original commits. Closing #13603
in favor of this combined PR.

Fixes #13545
Fixes #13575

---------

Co-authored-by: Connor Byrne <c.byrne@comfy.org>
Co-authored-by: xmarre <54859656+xmarre@users.noreply.github.com>
2026-07-14 18:14:47 +00:00
Terry Jia
ab33746b3e fix(load3d): letterbox-aware pointer NDC for the transform gizmo (#13550)
## Summary
The viewport letterboxes the scene to the width/height target aspect,
but TransformControls maps pointers over the full canvas rect, so its
handle raycasts skew toward the center, increasingly wrong toward the
edges whenever the canvas aspect differs from the target aspect.

Adds clientPointToLetterboxNdc() (pure, unit-tested) + a
Viewport3d.clientPointToNdc() wrapper, and routes TransformControls'
pointer math through it via GizmoManager.setPointerNdcSource(). 
Points on the letterbox bars resolve to nothing instead of phantom
handles.

## Screenshots (if applicable)
before
<img width="813" height="703" alt="image"
src="https://github.com/user-attachments/assets/cf5714c3-6b60-4ced-84ce-039f49c7e999"
/>
<img width="673" height="696" alt="image"
src="https://github.com/user-attachments/assets/31f898d2-a820-4748-b960-4f3cc5752095"
/>

after
<img width="1391" height="981" alt="image"
src="https://github.com/user-attachments/assets/18890d89-ff50-4298-bac5-472e00c79e24"
/>
<img width="955" height="712" alt="image"
src="https://github.com/user-attachments/assets/255f3c7d-dbb5-40a8-8865-c023caa1384e"
/>
2026-07-14 13:39:03 -04:00
Benjamin Lu
55c4e807a1 fix: use system hour cycle for queue times (#12297)
## Summary

Use the selected app locale for queue timestamp language and punctuation
while honoring the browser/system 12-hour or 24-hour clock preference.

## Changes

- **What**: `formatClockTime` resolves the hour cycle from the
browser/runtime default locale, then applies it while formatting with
the app locale.
- **What**: Added behavioral unit coverage for the production
two-argument path and explicit 12-hour and 24-hour preference locales.
- **Breaking**: None.
- **Dependencies**: None.

## Review Focus

Queue timestamps already use the browser's local timezone. This PR fixes
only the remaining clock-preference mismatch: app language stays in
control of the rendered time string, while the browser/system locale
controls 12-hour versus 24-hour display.

No Playwright regression test was added because browser/system
hour-cycle preference is environment-level Intl state, not deterministic
UI state that the E2E suite can set reliably. Unit tests pin both hour
cycles through BCP-47 locale preferences and assert the rendered
behavior directly.

Fixes
[FE-252](https://linear.app/comfyorg/issue/FE-252/bug-queue-progress-times-ignore-browsersystem-1224-hour-preference)

## Screenshots (if applicable)

Not applicable.

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2026-07-14 16:58:25 +00:00
jaeone94
74147d7ee2 feat: lift boundary-exposed validation errors to the subgraph host (#13542)
## Summary

Backend validation errors (`node_errors`) are reported against the
**flattened** prompt, so an error whose real fix lives on a subgraph
host node gets attached to an interior node the user may never open —
and, after ADR 0009, often *cannot* meaningfully fix there. This PR
re-surfaces a validation error onto the subgraph host node **when — and
only when — the error's subject (the specific input/widget named by
`extra_info.input_name`) is exposed through the subgraph boundary**.

## Why this is needed

Two concrete situations motivated this, both observed in real workflows:

1. **Broken link at the host.** Root node A should feed subgraph host B,
whose boundary input is linked to interior node C. If the A→B link is
missing, the backend flattens the prompt, sees C with no resolved input,
and raises `required_input_missing` **on C** (`"B:C"`). The actual fix —
connect B's input — is one level up, on a node the error never points
at.
2. **Host-owned widget values.** Per ADR 0009 (subgraph promoted widgets
use linked inputs), a promoted widget's value is owned by the host
`SubgraphNode`; the interior widget only supplies schema/defaults. When
the backend raises `value_not_in_list` (or min/max violations) for that
value, attributing it to the interior node is factually wrong — the
value that failed validation *is the host's value*.

This continues the direction of #13059, which moved **missing-model**
detection identity to `{hostExecutionId, hostWidgetName}` with the
interior path kept as diagnostics. That was possible in the FE pre-scan;
this PR applies the same ownership principle to **backend-received**
errors via a receive-side mapping, since the backend cannot know about
subgraph boundaries in a flattened prompt.

## The rule (design)

> Lift an error from interior node N to host H **iff** N's input slot
named by the error is linked to the containing subgraph's boundary
(`SubgraphInput`). Apply the same test again at H (boundary-by-boundary,
matching ADR 0009's chaining principle) and stop at the first level
where the subject is no longer boundary-linked — that node is where the
user can actually fix it.

The predicate is **structural (boundary exposure), not data-flow**.

### In scope — examples

- `required_input_missing` on interior `"12:5"` whose input is fed by
the boundary → surfaces on host `12`'s input slot (red slot ring on the
host, errors-tab card titled/located at the host, message names the
host's `SubgraphInput.name`).
- `value_not_in_list` / `value_smaller_than_min` /
`value_bigger_than_max` on a promoted interior widget → surfaces on the
host's promoted widget. Nested hosts chain: `"1:2:3"` lifts to `"1:2"`,
and further to `"1"` only if `1:2`'s own slot is boundary-linked too.
- Clearing follows the surface: connecting the highlighted host input or
fixing the host widget clears the underlying interior (raw) error —
range-guarded per target, so a still-out-of-range host value does
**not** clear.

### Out of scope — examples

- **No value-flow ancestry.** All in the root graph: A's widget links to
B, B's to C, and C rejects the value that originated at A → the error
**stays on C**. Following same-graph links to a "root cause" node is
explicitly not this feature.
- Errors without an `input_name` subject, node-level types
(`exception_during_validation`, `dependency_cycle`, image-not-loaded),
and unknown validation types — never lifted. Unknown types stay
node-scoped to match how the error catalog renders them (the shared
`isNodeLevelValidationError` in `executionErrorUtil` encodes this, and
the catalog derives its node-level rules from the same set).
- Runtime execution errors (exceptions during a run) — validation
responses only.
- Interior errors whose input is fed by another interior node — fixable
in place, stay in place.
- Fan-out display dedupe: when one boundary input feeds multiple
interior nodes and the host slot is unconnected, each interior error
lifts to the same host slot as a separate panel line. A single fix
(connecting the host input) clears all of them — the clearing
translation already fans out — so the duplication is cosmetic.
Display-level dedupe is a follow-up; deduping inside the lift would
break the one-source-per-error clearing contract.
- Reactive re-lifting on graph topology edits while errors are displayed
(invariant documented on the computed; follow-up), and deriving the
error catalog's full validation rule table from the shared
classification (follow-up; the node-level type set and the
image-not-loaded predicate are already single-sourced in
`executionErrorUtil` and consumed by both the lift and the catalog).

## Changes

- **What**: New pure module
`core/graph/subgraph/liftNodeErrorsToBoundary.ts` — per-error, fail-open
record transform (unresolvable ids/slots/links leave the error where the
backend put it; raw payload is never mutated). `executionErrorStore`
derives `surfacedNodeErrors` from it and display consumers switch over
(errors tab grouping, canvas node/slot flags, Vue node badges,
app-mode/linear hints); raw `lastNodeErrors` remains the source of truth
for mutation. Host-side clearing translates through the lift's
diagnostics fields (`source_execution_id` / `source_input_name`) with a
per-target range guard.
- **Breaking**: None. No persistence/serialization changes; interior
identity survives as diagnostics metadata only (ADR 0009 language).

## Review Focus

- The lift predicate lives entirely on link topology
(`LLink.originIsIoNode` → `SubgraphInput`) — no
`proxyWidgets`/promotion-store style source authority is reintroduced.
- `clearSlotErrorsWithRangeCheck` now resolves clear targets first and
range-checks each target's raw errors; the lifted-path twin of the
existing range-retention test pins this.
- `useProcessedWidgets` deliberately stays on the raw record: host
promoted widgets already map to interior errors via
`widget.sourceExecutionId`, so an interior widget keeps its local red
hint when the user opens the subgraph (hint layer vs surface layer).
- Unit coverage is carried by the pure module (real litegraph subgraph
fixtures, incl. nested recursion, promoted widgets via
`promoteValueWidgetViaSubgraphInput`, ordering, fail-open/no-mutation);
one e2e pins the user-visible contract (host ring + host slot dot +
interior clean).

## Screenshots

### Before 


https://github.com/user-attachments/assets/81e5c4db-515d-4f1f-8f8a-e07ac490510f

### After



https://github.com/user-attachments/assets/2949da06-a049-41c1-a480-98ee28333bf2
2026-07-14 14:39:20 +00:00
149 changed files with 7680 additions and 1366 deletions

View File

@@ -354,7 +354,9 @@ export class AssetsSidebarTab extends SidebarTab {
)
this.selectionFooter = page.getByTestId('assets-selection-bar')
this.selectionCountButton = page.getByText(/\d+ selected/)
this.deselectAllButton = page.getByTestId('assets-deselect-selected')
this.deselectAllButton = page.getByRole('button', {
name: 'Deselect all'
})
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
this.backToAssetsButton = page.getByText('Back to all assets')

View File

@@ -205,30 +205,27 @@ test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
await expect(content.getByText('Total credits')).toBeVisible()
await expect(content.getByText('12,660')).toBeVisible()
// Monthly usage bar header + used / left-of-total labels.
await expect(content.getByText('Monthly', { exact: true })).toBeVisible()
await expect(content.getByText(/Refills Feb/)).toBeVisible()
await expect(content.getByText('10,550 used')).toBeVisible()
await expect(content.getByText('10,550 left of 21,100')).toBeVisible()
await expect(content.getByText('50% used')).toBeVisible()
// Additional credits row + subtitle.
await expect(content.getByText('Additional credits')).toBeVisible()
await expect(content.getByText('2,110')).toBeVisible()
await expect(content.getByText('Used after monthly runs out')).toBeVisible()
await expect(
content.getByText('Used after plan credits run out')
).toBeVisible()
// Permission-gated add-credits action (personal owner can top up).
await expect(
content.getByRole('button', { name: 'Add credits' })
).toBeVisible()
// Narrow container (DES-247 responsive variants): drop the used/remaining
// labels and the breakdown subtitle, compact the monthly summary numbers.
await page.setViewportSize({ width: 360, height: 800 })
await expect(content.getByText('10,550 used')).toBeHidden()
await expect(content.getByText('remaining', { exact: true })).toBeHidden()
await expect(content.getByText('Used after monthly runs out')).toBeHidden()
await expect(content.getByText('10,550 left of 21,100')).toBeHidden()
await expect(content.getByText('11K left of 21K')).toBeVisible()
await expect(
content.getByText('Used after plan credits run out')
).toBeHidden()
await expect(content.getByText('50% used')).toBeVisible()
})
test('renders the depleted-credit empty states', async ({ page }) => {
@@ -240,27 +237,17 @@ test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
const content = await openPlanAndCredits(page)
// 0-monthly state: depletion notice + IN USE badge on additional credits.
await expect(
content.getByText('Monthly credits are used up. Refills Feb 20')
).toBeVisible()
await expect(
content.getByText("You're now spending additional credits.")
).toBeVisible()
await expect(content.getByText('100% used')).toBeVisible()
await expect(content.getByText('In use')).toBeVisible()
await expect(content.getByText('0 left of 21,100')).toBeVisible()
await expect(
content.getByText('2,110', { exact: true }).last()
).toBeVisible()
// Drain the remaining additional credits and refresh the tile: the
// out-of-credits notice takes over and the badge drops.
await mockBalance(page, { amount: 0, monthly: 0, prepaid: 0 })
await content.getByRole('button', { name: 'Refresh credits' }).click()
await expect(
content.getByText("You're out of credits. Credits refill Feb 20")
).toBeVisible()
await expect(
content.getByText('Add more credits to continue generating.')
).toBeVisible()
await expect(content.getByText('0', { exact: true }).first()).toBeVisible()
await expect(content.getByText('100% used')).toBeVisible()
await expect(content.getByText('In use')).toBeHidden()
await expect(
content.getByRole('button', { name: 'Add credits' })

View File

@@ -22,7 +22,7 @@ import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMo
* The viewer is a promoted owner (not the workspace creator), so the spec can
* distinguish the creator guard from the self guard: the creator row and the
* viewer's own row hide the row menu, every other row exposes
* "Change role " (Owner / Member) plus "Remove member". Promoting a member
* "Change role " (Admin / Member) plus "Remove member". Promoting a member
* sends PATCH /api/workspace/members/:id {role}, flips the Role column,
* re-sorts the row under the creator, and the promoted owner stays demotable.
*/
@@ -44,13 +44,15 @@ async function openMembersTab(page: Page): Promise<Locator> {
const content = dialog.getByRole('main')
await content.getByRole('tab', { name: /Members/ }).click()
await expect(content.getByText('4 of 30 members')).toBeVisible()
await expect(
content.getByRole('tabpanel', { name: 'Members (4)' }).getByRole('table')
).toBeVisible()
return content
}
function memberRow(content: Locator, email: string): Locator {
return content
.locator('div.grid')
.getByRole('row')
.filter({ has: content.page().getByText(email, { exact: true }) })
}
@@ -66,7 +68,7 @@ async function openChangeRoleSubmenu(page: Page) {
await expect(trigger).toBeVisible()
await trigger.press('ArrowRight')
await expect(
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
page.getByRole('menuitemradio', { name: 'Admin', exact: true })
).toBeVisible()
}
@@ -111,14 +113,14 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
page.getByRole('menuitemradio', { name: 'Member', exact: true })
).toHaveAttribute('aria-checked', 'true')
await expect(
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
page.getByRole('menuitemradio', { name: 'Admin', exact: true })
).toHaveAttribute('aria-checked', 'false')
await page
.getByRole('menuitemradio', { name: 'Member', exact: true })
.click()
.press('Enter')
await expect(page.getByRole('heading', { name: /an owner\?/ })).toHaveCount(
await expect(page.getByRole('heading', { name: /an admin\?/ })).toHaveCount(
0
)
expect(state.patches).toHaveLength(0)
@@ -134,11 +136,11 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
.getByRole('menuitemradio', { name: 'Admin', exact: true })
.press('Enter')
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
page.getByRole('heading', { name: 'Make Jane an admin?' })
).toBeVisible()
await expect(page.getByText("They'll be able to:")).toBeVisible()
await expect(page.getByText('Add additional credits')).toBeVisible()
@@ -147,7 +149,7 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
).toBeVisible()
await expect(
page.getByText(
'Promote and demote other owners (except the workspace creator).'
'Promote and demote other admins (except the workspace creator).'
)
).toBeVisible()
@@ -177,12 +179,12 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await page.getByRole('button', { name: 'Make owner' }).click()
.getByRole('menuitemradio', { name: 'Admin', exact: true })
.press('Enter')
await page.getByRole('button', { name: 'Make admin' }).click()
await expect(page.getByText('Role updated')).toBeVisible()
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
await expect(janeRow.getByText('Admin', { exact: true })).toBeVisible()
await expect(emails).toHaveText([
CREATOR.email,
VIEWER.email,
@@ -211,13 +213,13 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
const content = await openMembersTab(page)
const janeRow = memberRow(content, MEMBER_JANE.email)
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
await expect(janeRow.getByText('Admin', { exact: true })).toBeVisible()
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Member', exact: true })
.click()
.press('Enter')
await expect(
page.getByRole('heading', { name: 'Demote Jane to member?' })
).toBeVisible()
@@ -249,14 +251,14 @@ test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
await menuButton(janeRow).click()
await openChangeRoleSubmenu(page)
await page
.getByRole('menuitemradio', { name: 'Owner', exact: true })
.click()
await page.getByRole('button', { name: 'Make owner' }).click()
.getByRole('menuitemradio', { name: 'Admin', exact: true })
.press('Enter')
await page.getByRole('button', { name: 'Make admin' }).click()
// US10 — error toast, dialog stays open, role unchanged.
await expect(page.getByText('Failed to update role')).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Make Jane an owner?' })
page.getByRole('heading', { name: 'Make Jane an admin?' })
).toBeVisible()
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -40,7 +40,7 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
)
await expect(missingNodeCard.getByText('Unknown pack')).toBeVisible()
await expect(
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE', exact: true })
missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' })
).toBeVisible()
})
@@ -57,8 +57,7 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
)
await expect(
missingNodeCard.getByRole('button', {
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH',
exact: true
name: 'MISSING_NODE_TYPE_IN_SUBGRAPH'
})
).toBeVisible()
})
@@ -74,9 +73,7 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
await comfyPage.canvasOps.pan({ x: -800, y: -800 })
const offsetBeforeLocate = await comfyPage.canvasOps.getOffset()
await missingNodeCard
.getByRole('button', { name: 'UNKNOWN NODE', exact: true })
.click()
await missingNodeCard.getByRole('button', { name: 'UNKNOWN NODE' }).click()
await expect
.poll(() => comfyPage.canvasOps.getOffset())
@@ -101,12 +98,10 @@ test.describe('Errors tab - Missing nodes', { tag: ['@ui', '@canvas'] }, () => {
TestIds.dialogs.missingNodePackExpand
)
const firstNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_A',
exact: true
name: 'TEST_MISSING_PACK_NODE_A'
})
const secondNode = missingNodeCard.getByRole('button', {
name: 'TEST_MISSING_PACK_NODE_B',
exact: true
name: 'TEST_MISSING_PACK_NODE_B'
})
await expect(packTitle).toBeVisible()

View File

@@ -23,6 +23,7 @@ import { webSocketFixture } from '@e2e/fixtures/ws'
const test = mergeTests(comfyPageFixture, webSocketFixture)
const ERROR_CLASS = /ring-destructive-background/
const SLOT_ERROR_CLASS = /before:ring-error/
const UNKNOWN_NODE_ID = '1'
const INNER_EXECUTION_ID = '2:1'
const KSAMPLER_MODEL_INPUT_NAME = 'model'
@@ -69,6 +70,25 @@ async function selectLoadImageNodeForPaste(
}, localLoadImageId)
}
async function getInputSlotIndexByName(
comfyPage: ComfyPage,
nodeId: string,
inputName: string
): Promise<number> {
return comfyPage.page.evaluate(
({ inputName, nodeId }) => {
const graph = window.app!.canvas.graph ?? window.app!.graph
const node = graph.getNodeById(nodeId)
const index = node?.findInputSlot(inputName) ?? -1
if (index < 0) {
throw new Error(`Input slot "${inputName}" not found`)
}
return index
},
{ inputName, nodeId: toNodeId(nodeId) }
)
}
async function setupLoadImageErrorScenario(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
const loadImageNode = (
@@ -139,17 +159,10 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
async ({ comfyPage }) => {
const ksamplerId = await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const ksamplerNode = comfyPage.vueNodes.getNodeLocator(ksamplerId)
const modelInputIndex = await comfyPage.page.evaluate(
({ nodeId, inputName }) => {
const node = window.app!.graph.getNodeById(nodeId)
const index =
node?.inputs?.findIndex((input) => input.name === inputName) ?? -1
if (index < 0) {
throw new Error(`Input slot "${inputName}" not found`)
}
return index
},
{ nodeId: toNodeId(ksamplerId), inputName: KSAMPLER_MODEL_INPUT_NAME }
const modelInputIndex = await getInputSlotIndexByName(
comfyPage,
ksamplerId,
KSAMPLER_MODEL_INPUT_NAME
)
const modelInputSlotRow = comfyPage.vueNodes.getInputSlotRow(
ksamplerId,
@@ -175,7 +188,7 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(modelInputSlotRow).toBeVisible()
await expect(modelInputSlotRow).toBeInViewport()
await expect(modelInputSlotHighlight).toHaveClass(/before:ring-error/)
await expect(modelInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
await expect(
comfyPage.vueNodes.getNodeInnerWrapper(ksamplerId)
).toHaveClass(ERROR_CLASS)
@@ -407,5 +420,76 @@ test.describe('Vue Node Error', { tag: '@vue-nodes' }, () => {
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
})
test('boundary-linked validation error surfaces on the subgraph host', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphParentId =
await comfyPage.vueNodes.getNodeIdByTitle('New Subgraph')
const innerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(subgraphParentId)
const hostInputIndex = await getInputSlotIndexByName(
comfyPage,
subgraphParentId,
'positive'
)
const hostInputSlotHighlight =
comfyPage.vueNodes.getInputSlotConnectionDot(
subgraphParentId,
hostInputIndex
)
await expect(
innerWrapper,
'subgraph host must mount before injecting validation errors'
).toBeVisible()
await expect(
innerWrapper,
'subgraph host should start without an error ring'
).not.toHaveClass(ERROR_CLASS)
await test.step('surface the boundary-linked error on the host', async () => {
const exec = new ExecutionHelper(comfyPage)
await exec.mockValidationFailure({
[INNER_EXECUTION_ID]: buildKSamplerError(
'required_input_missing',
'positive',
'Required input is missing: positive'
)
})
await comfyPage.runButton.click()
await dismissErrorOverlay(comfyPage)
await expect(innerWrapper).toHaveClass(ERROR_CLASS)
await expect(hostInputSlotHighlight).toHaveClass(SLOT_ERROR_CLASS)
})
await test.step('confirm the interior node does not show the surfaced ring', async () => {
await comfyPage.vueNodes.enterSubgraph(subgraphParentId)
await comfyPage.nextFrame()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
const interiorKSamplerId =
await comfyPage.vueNodes.getNodeIdByTitle('KSampler')
const interiorPositiveInputIndex = await getInputSlotIndexByName(
comfyPage,
interiorKSamplerId,
'positive'
)
const interiorPositiveSlotHighlight =
comfyPage.vueNodes.getInputSlotConnectionDot(
interiorKSamplerId,
interiorPositiveInputIndex
)
const interiorInnerWrapper =
comfyPage.vueNodes.getNodeInnerWrapper(interiorKSamplerId)
await expect(interiorInnerWrapper).toBeVisible()
await expect(interiorInnerWrapper).not.toHaveClass(ERROR_CLASS)
await expect(interiorPositiveSlotHighlight).toBeVisible()
await expect(interiorPositiveSlotHighlight).not.toHaveClass(
SLOT_ERROR_CLASS
)
})
})
})
})

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
appendWorkflowJsonExt,
ensureWorkflowSuffix,
escapeVueI18nMessageSyntax,
formatLocalizedMediumDate,
formatLocalizedNumber,
getFilePathSeparatorVariants,
@@ -477,4 +478,49 @@ describe('formatUtil', () => {
expect(formatLocalizedMediumDate('not a date', 'en')).toBe('—')
})
})
describe('escapeVueI18nMessageSyntax', () => {
it('escapes a literal @ that would break linked-message compilation', () => {
expect(
escapeVueI18nMessageSyntax('clips (tagged @Audio1-3 in the prompt)')
).toBe("clips (tagged {'@'}Audio1-3 in the prompt)")
})
it('escapes @ in an email address', () => {
expect(escapeVueI18nMessageSyntax('support@comfy.org')).toBe(
"support{'@'}comfy.org"
)
})
it('escapes interpolation braces', () => {
expect(escapeVueI18nMessageSyntax('size {w}x{h}')).toBe(
"size {'{'}w{'}'}x{'{'}h{'}'}"
)
})
it('escapes the plural pipe separator', () => {
expect(escapeVueI18nMessageSyntax('foreground | background')).toBe(
"foreground {'|'} background"
)
})
it('escapes the modulo percent so it cannot re-form %{', () => {
expect(escapeVueI18nMessageSyntax('50%{done}')).toBe(
"50{'%'}{'{'}done{'}'}"
)
})
it('escapes every occurrence in a single pass', () => {
expect(escapeVueI18nMessageSyntax('@a @b @c')).toBe(
"{'@'}a {'@'}b {'@'}c"
)
})
it('leaves strings without syntax characters unchanged', () => {
expect(escapeVueI18nMessageSyntax('no special chars here')).toBe(
'no special chars here'
)
expect(escapeVueI18nMessageSyntax('')).toBe('')
})
})
})

View File

@@ -178,6 +178,40 @@ export function normalizeI18nKey(key: string) {
return typeof key === 'string' ? key.replace(/\./g, '_') : ''
}
/**
* Characters that vue-i18n's message compiler treats as syntax in message text,
* so plain text has to escape them to render verbatim through `t()`/`st()`:
*
* - `@` starts a linked-message reference (`@:key`); malformed usage throws
* `Invalid linked format`.
* - `{` / `}` delimit interpolation (`{name}`, `{'literal'}`); an unbalanced
* brace throws `Unterminated/Unbalanced closing brace`.
* - `|` separates plural branches, so `a | b` silently renders as one branch.
* - `%` forms modulo interpolation when immediately followed by `{` (`%{name}`);
* it must be escaped too, otherwise escaping a following `{` re-forms `%{`.
*
* The set is a build-inlined `const enum` (`TokenChars`) in
* `@intlify/message-compiler` and is not exported, so it is hardcoded here.
*
* @see https://vue-i18n.intlify.dev/guide/essentials/syntax (Special Characters, Literal interpolation)
* @see https://vue-i18n.intlify.dev/guide/essentials/pluralization
*/
const VUE_I18N_SYNTAX_CHARS = /[@{}|%]/g
/**
* Escapes vue-i18n message-syntax characters as literal interpolations (`{'x'}`)
* so arbitrary text renders verbatim instead of being parsed as syntax. This is
* the only escape vue-i18n supports; see {@link VUE_I18N_SYNTAX_CHARS}.
*
* Only apply to values read through the compiler (`t()`/`st()`). Values read raw
* via `tm()`/`stRaw()` (e.g. node tooltips) must be left untouched, or the
* literal `{'x'}` would surface to users. Apply exactly once to raw text: the
* escape output itself contains `{`/`}`, so it is not idempotent.
*/
export function escapeVueI18nMessageSyntax(text: string): string {
return text.replace(VUE_I18N_SYNTAX_CHARS, (char) => `{'${char}'}`)
}
/**
* Takes a dynamic prompt in the format {opt1|opt2|{optA|optB}|} and randomly replaces groups. Supports C style comments.
* @param input The dynamic prompt to process

View File

@@ -3,7 +3,10 @@ import * as fs from 'fs'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { comfyPageFixture as test } from '../browser_tests/fixtures/ComfyPage'
import { normalizeI18nKey } from '../packages/shared-frontend-utils/src/formatUtil'
import {
escapeVueI18nMessageSyntax,
normalizeI18nKey
} from '@/utils/formatUtil'
import type { ComfyNodeDefImpl } from '../src/stores/nodeDefStore'
const localePath = './src/locales/en/main.json'
@@ -44,8 +47,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
}
)
console.log(`Collected ${nodeDefs.length} node definitions`)
const allDataTypesLocale = Object.fromEntries(
nodeDefs
.flatMap((nodeDef) => {
@@ -60,7 +61,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
)
return allDataTypes.map((dataType) => [
normalizeI18nKey(dataType),
dataType
escapeVueI18nMessageSyntax(dataType)
])
})
.sort((a, b) => a[0].localeCompare(b[0]))
@@ -98,7 +99,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
const runtimeWidgets = Object.fromEntries(
Object.entries(widgetsMappings)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([key, value]) => [normalizeI18nKey(key), { name: value }])
.map(([key, value]) => [
normalizeI18nKey(key),
{ name: value ? escapeVueI18nMessageSyntax(value) : value }
])
)
if (Object.keys(runtimeWidgets).length > 0) {
@@ -121,7 +125,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
function extractInputs(nodeDef: ComfyNodeDefImpl) {
const inputs = Object.fromEntries(
Object.values(nodeDef.inputs).flatMap((input) => {
const name = input.name
const name =
input.name === undefined
? undefined
: escapeVueI18nMessageSyntax(input.name)
const tooltip = input.tooltip
if (name === undefined && tooltip === undefined) {
@@ -146,7 +153,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
const outputs = Object.fromEntries(
nodeDef.outputs.flatMap((output, i) => {
// Ignore data types if they are already translated in allDataTypesLocale.
const name = output.name in allDataTypesLocale ? undefined : output.name
const name =
output.name === undefined || output.name in allDataTypesLocale
? undefined
: escapeVueI18nMessageSyntax(output.name)
const tooltip = output.tooltip
if (name === undefined && tooltip === undefined) {
@@ -179,8 +189,12 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
return [
normalizeI18nKey(nodeDef.name),
{
display_name: nodeDef.display_name ?? nodeDef.name,
description: nodeDef.description || undefined,
display_name: escapeVueI18nMessageSyntax(
nodeDef.display_name ?? nodeDef.name
),
description: nodeDef.description
? escapeVueI18nMessageSyntax(nodeDef.description)
: undefined,
inputs: Object.keys(inputs).length > 0 ? inputs : undefined,
outputs: extractOutputs(nodeDef)
}
@@ -192,7 +206,10 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
nodeDefs.flatMap((nodeDef) =>
nodeDef.category
.split('/')
.map((category) => [normalizeI18nKey(category), category])
.map((category) => [
normalizeI18nKey(category),
escapeVueI18nMessageSyntax(category)
])
)
)

View File

@@ -7,12 +7,14 @@ import type {
JobListItem,
JobStatus
} from '@/platform/remote/comfyui/jobs/jobTypes'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { useCommandStore } from '@/stores/commandStore'
import {
TaskItemImpl,
useQueueSettingsStore,
useQueueStore
} from '@/stores/queueStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
@@ -62,7 +64,8 @@ const i18n = createI18n({
stopRunInstantTooltip: 'Stop running',
runWorkflow: 'Run workflow',
runWorkflowFront: 'Run workflow front',
runWorkflowDisabled: 'Run workflow disabled'
runWorkflowDisabled: 'Run workflow disabled',
runWorkflowDisabledNodes: 'Run workflow disabled nodes'
}
}
}
@@ -182,4 +185,32 @@ describe('ComfyQueueButton', () => {
}
})
})
it('keeps instant mode idle while dispatching a disabled-node queue command', async () => {
const { user } = renderQueueButton()
const queueSettingsStore = useQueueSettingsStore()
const commandStore = useCommandStore()
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
disabledPartnerNodesStore.offenders = [
{
nodeId: createNodeExecutionId([1]),
displayName: 'Blocked Partner Node'
}
]
queueSettingsStore.mode = 'instant-idle'
await nextTick()
await user.click(screen.getByTestId('queue-button'))
await nextTick()
expect(queueSettingsStore.mode).toBe('instant-idle')
expect(disabledPartnerNodesStore.scanGraph).toHaveBeenCalledOnce()
expect(commandStore.execute).toHaveBeenCalledWith('Comfy.QueuePrompt', {
metadata: {
subscribe_to_run: false,
trigger_source: 'button'
}
})
})
})

View File

@@ -82,6 +82,7 @@ import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
isInstantMode,
@@ -99,6 +100,10 @@ const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.rootGraph, nodeDefStore.nodeDefsByName)
)
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
const hasDisabledNodes = computed(
() => disabledPartnerNodesStore.offenders.length > 0
)
const { t } = useI18n()
type QueueModeMenuKey = 'disabled' | 'change' | 'instant-idle'
@@ -190,7 +195,7 @@ const iconClass = computed(() => {
if (isStopInstantAction.value) {
return 'icon-[lucide--square]'
}
if (hasMissingNodes.value) {
if (hasMissingNodes.value || hasDisabledNodes.value) {
return 'icon-[lucide--triangle-alert]'
}
if (workspaceStore.shiftDown) {
@@ -215,6 +220,9 @@ const queueButtonTooltip = computed(() => {
if (hasMissingNodes.value) {
return t('menu.runWorkflowDisabled')
}
if (hasDisabledNodes.value) {
return t('menu.runWorkflowDisabledNodes')
}
if (workspaceStore.shiftDown) {
return t('menu.runWorkflowFront')
}
@@ -233,7 +241,8 @@ const queuePrompt = async (e: Event) => {
? 'Comfy.QueuePromptFront'
: 'Comfy.QueuePrompt'
if (isInstantMode(queueMode.value)) {
disabledPartnerNodesStore.scanGraph()
if (!hasDisabledNodes.value && isInstantMode(queueMode.value)) {
queueMode.value = 'instant-running'
}

View File

@@ -126,7 +126,7 @@ function nodeToNodeData(node: LGraphNode) {
return {
...nodeData,
hasErrors: !!executionErrorStore.lastNodeErrors?.[node.id],
hasErrors: !!executionErrorStore.surfacedNodeErrors?.[node.id],
dropIndicator,
onDragDrop: node.onDragDrop,
onDragOver: node.onDragOver

View File

@@ -19,7 +19,11 @@ defineOptions({
inheritAttrs: false
})
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
const {
itemClass: itemProp,
contentClass: contentProp,
modal = true
} = defineProps<{
entries?: MenuItem[]
icon?: string
to?: string | HTMLElement
@@ -27,6 +31,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
contentClass?: string
buttonSize?: ButtonVariants['size']
buttonClass?: string
modal?: boolean
}>()
const itemClass = computed(() =>
@@ -48,7 +53,7 @@ const contentStyle = useModalLiftedZIndex(open)
</script>
<template>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuRoot v-model:open="open" :modal>
<DropdownMenuTrigger as-child>
<slot name="button">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">

View File

@@ -0,0 +1,43 @@
<template>
<div class="relative mx-2">
<div
v-bind="$attrs"
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
>
<Button
v-tooltip.top="{ value: deselectLabel, showDelay: 300 }"
variant="inverted"
size="icon-lg"
type="button"
:aria-label="deselectLabel"
class="rounded-lg hover:bg-base-background/10"
@click="emit('deselect')"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
{{ label }}
</span>
<div class="ml-auto flex shrink-0 items-center gap-1">
<slot />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
defineOptions({ inheritAttrs: false })
defineProps<{
/** The "N selected" text; the caller formats it (pluralization, wording). */
label: string
/** Accessible label + tooltip for the deselect button. */
deselectLabel: string
}>()
const emit = defineEmits<{
deselect: []
}>()
</script>

View File

@@ -14,7 +14,7 @@
class="p-1 text-amber-400"
>
<template #icon>
<i class="icon-[lucide--component]" />
<i class="icon-[lucide--coins]" />
</template>
</Tag>
<div :class="textClass">

View File

@@ -404,6 +404,18 @@ describe('shouldPreventRekaDismiss', () => {
expect(event.defaultPrevented).toBe(false)
})
it('allows dismiss when target is an outside popup trigger', () => {
const trigger = document.createElement('button')
trigger.setAttribute('aria-haspopup', 'menu')
document.body.appendChild(trigger)
const event = makeEvent(trigger)
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
expect(event.defaultPrevented).toBe(false)
trigger.remove()
})
it('prevents dismiss when the dialog is not the top-most (stacked)', () => {
// A backgrounded dialog must never dismiss on an outside pointer — the
// pointer belongs to the dialog stacked above it (e.g. Edit Keybinding

View File

@@ -86,7 +86,7 @@
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
@@ -98,7 +98,7 @@
v-if="isBelowMin"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
>
<i class="icon-[lucide--component] size-4" />
<i class="icon-[lucide--coins] size-4" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
@@ -109,7 +109,7 @@
v-if="showCeilingWarning"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
>
<i class="icon-[lucide--component] size-4" />
<i class="icon-[lucide--coins] size-4" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))

View File

@@ -7,7 +7,7 @@
)
"
>
<i class="icon-[lucide--component] h-full bg-amber-400" />
<i class="icon-[lucide--coins] h-full bg-amber-400" />
<span class="truncate" v-text="text" />
</span>
<span

View File

@@ -38,6 +38,7 @@ import {
} from './shared'
import SubgraphEditor from './subgraph/SubgraphEditor.vue'
import TabErrors from './errors/TabErrors.vue'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
@@ -157,14 +158,33 @@ const hasMissingMediaSelected = computed(
)
)
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
const activeDisabledGraphNodeIds = computed<Set<string>>(() => {
if (!app.isGraphReady) return new Set()
return getActiveGraphNodeIds(
app.rootGraph,
canvasStore.currentGraph ?? app.rootGraph,
disabledPartnerNodesStore.disabledAncestorExecutionIds
)
})
const hasDisabledNodeSelected = computed(
() =>
hasSelection.value &&
selectedNodes.value.some((node) =>
activeDisabledGraphNodeIds.value.has(String(node.id))
)
)
const hasRelevantErrors = computed(() => {
if (!hasSelection.value) return hasAnyError.value
if (!hasSelection.value)
return hasAnyError.value || disabledPartnerNodesStore.offenders.length > 0
return (
hasDirectNodeError.value ||
hasContainerInternalError.value ||
hasMissingNodeSelected.value ||
hasMissingModelSelected.value ||
hasMissingMediaSelected.value
hasMissingMediaSelected.value ||
hasDisabledNodeSelected.value
)
})

View File

@@ -248,14 +248,19 @@
<i class="icon-[lucide--info] size-3.5" />
</Button>
</span>
<LocateNodeButton
:label="
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="
t('rightSidePanel.locateNodeFor', {
item: item.label
})
"
@locate="handleLocateNode(item.nodeId)"
/>
@click.stop="handleLocateNode(item.nodeId)"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<p
@@ -304,6 +309,11 @@
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateAssetNode"
/>
<DisabledNodesCard
v-if="group.type === 'disabled_node'"
:offenders="disabledPartnerNodesStore.offenders"
@locate-node="handleLocateAssetNode"
/>
</ErrorCardSection>
</TransitionGroup>
</div>
@@ -327,15 +337,16 @@ import TransitionCollapse from '../layout/TransitionCollapse.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import ErrorCardSection from './ErrorCardSection.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import LocateNodeButton from './LocateNodeButton.vue'
import MissingNodeCard from './MissingNodeCard.vue'
import SwapNodesCard from '@/platform/nodeReplacement/components/SwapNodesCard.vue'
import MissingModelCard from '@/platform/missingModel/components/MissingModelCard.vue'
import MissingMediaCard from '@/platform/missingMedia/components/MissingMediaCard.vue'
import DisabledNodesCard from '@/platform/workspace/components/errors/DisabledNodesCard.vue'
import { isCloud } from '@/platform/distribution/types'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups'
@@ -358,6 +369,7 @@ const { copyToClipboard } = useCopyToClipboard()
const { focusNode } = useFocusNode()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
useManagerState()
const { missingNodePacks } = useMissingNodes()

View File

@@ -36,10 +36,15 @@
>
<i class="icon-[lucide--monitor-x] size-4" />
</Button>
<LocateNodeButton
:label="t('rightSidePanel.locateNode')"
@locate="handleLocateNode"
/>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="t('rightSidePanel.locateNode')"
@click.stop="handleLocateNode"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
</div>
@@ -172,7 +177,6 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
import LocateNodeButton from './LocateNodeButton.vue'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import type { ErrorCardData, ErrorItem } from './types'

View File

@@ -1,57 +0,0 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import LocateNodeButton from '@/components/rightSidePanel/errors/LocateNodeButton.vue'
describe('LocateNodeButton', () => {
it('exposes the label as the button aria-label', () => {
render(LocateNodeButton, { props: { label: 'Locate node on canvas' } })
expect(
screen.getByRole('button', { name: 'Locate node on canvas' })
).toHaveAttribute('aria-label', 'Locate node on canvas')
})
it('emits locate when clicked', async () => {
const user = userEvent.setup()
const { emitted } = render(LocateNodeButton, {
props: { label: 'Locate node on canvas' }
})
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(emitted().locate).toHaveLength(1)
})
it('emits locate on keyboard activation', async () => {
const user = userEvent.setup()
const { emitted } = render(LocateNodeButton, {
props: { label: 'Locate node on canvas' }
})
await user.tab()
await user.keyboard('{Enter}')
expect(emitted().locate).toHaveLength(1)
})
it('stops click propagation so an ancestor handler does not also fire', async () => {
const user = userEvent.setup()
const onAncestorClick = vi.fn()
render({
components: { LocateNodeButton },
setup: () => ({ onAncestorClick }),
template:
'<div @click="onAncestorClick"><LocateNodeButton label="Locate node on canvas" /></div>'
})
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(onAncestorClick).not.toHaveBeenCalled()
})
})

View File

@@ -1,23 +0,0 @@
<template>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="label"
@click.stop="emit('locate')"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { label } = defineProps<{
label: string
}>()
const emit = defineEmits<{
locate: []
}>()
</script>

View File

@@ -66,7 +66,7 @@ const i18n = createI18n({
search: 'Search'
},
rightSidePanel: {
locateNodeFor: 'Locate {item}',
locateNode: 'Locate node on canvas',
missingNodePacks: {
unknownPack: 'Unknown pack',
installing: 'Installing...',
@@ -162,7 +162,7 @@ describe('MissingPackGroupRow', () => {
})
expect(
screen.queryByRole('button', { name: 'Locate OnlyNode' })
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
@@ -263,7 +263,9 @@ describe('MissingPackGroupRow', () => {
})
})
await user.click(screen.getByRole('button', { name: 'Locate OnlyNode' }))
await user.click(
screen.getByRole('button', { name: 'Locate node on canvas' })
)
expect(onLocateNode).toHaveBeenCalledWith('100')
})
@@ -272,7 +274,9 @@ describe('MissingPackGroupRow', () => {
const { user, onLocateNode } = renderRow()
await user.click(screen.getByRole('button', { name: 'Expand' }))
await user.click(screen.getByRole('button', { name: 'Locate MissingA' }))
await user.click(
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
)
expect(onLocateNode).toHaveBeenCalledWith('10')
})
@@ -291,7 +295,7 @@ describe('MissingPackGroupRow', () => {
})
})
expect(
screen.queryByRole('button', { name: 'Locate NoId' })
screen.queryByRole('button', { name: 'Locate node on canvas' })
).not.toBeInTheDocument()
})
@@ -308,11 +312,8 @@ describe('MissingPackGroupRow', () => {
expect(screen.getByText('WithId')).toBeInTheDocument()
expect(screen.getByText('WithoutId')).toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'Locate WithId' })
).toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'Locate WithoutId' })
).not.toBeInTheDocument()
screen.getAllByRole('button', { name: 'Locate node on canvas' })
).toHaveLength(1)
})
})

View File

@@ -154,15 +154,16 @@
</span>
</Button>
</div>
<LocateNodeButton
<Button
v-if="primaryLocatableNodeType"
:label="
t('rightSidePanel.locateNodeFor', {
item: getLabel(primaryLocatableNodeType)
})
"
@locate="handleLocateNode(primaryLocatableNodeType)"
/>
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(primaryLocatableNodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
@@ -202,13 +203,16 @@
{{ getLabel(nodeType) }}
</span>
</span>
<LocateNodeButton
<Button
v-if="isLocatableNodeType(nodeType)"
:label="
t('rightSidePanel.locateNodeFor', { item: getLabel(nodeType) })
"
@locate="handleLocateNode(nodeType)"
/>
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset"
:aria-label="t('rightSidePanel.locateNode')"
@click="handleLocateNode(nodeType)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</ul>
@@ -224,7 +228,6 @@ import { cn } from '@comfyorg/tailwind-utils'
import { selectionEmphasisClass } from './selectionEmphasis'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import LocateNodeButton from '@/components/rightSidePanel/errors/LocateNodeButton.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'

View File

@@ -45,3 +45,6 @@ export type ErrorGroup =
| (ErrorGroupBase & {
type: 'missing_media'
})
| (ErrorGroupBase & {
type: 'disabled_node'
})

View File

@@ -5,9 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type * as GraphTraversalUtil from '@/utils/graphTraversalUtil'
vi.mock('@/scripts/app', () => ({
app: {
isGraphReady: true,
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
@@ -126,7 +128,10 @@ vi.mock(
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
import { createBoundaryLinkedSubgraph } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
getExecutionIdByNode,
getNodeByExecutionId
@@ -325,6 +330,39 @@ describe('useErrorGroups', () => {
expect(groups.allErrorGroups.value).toEqual([])
})
it('includes disabled nodes in the group and node summary', async () => {
const { groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
vi.mocked(getNodeByExecutionId).mockImplementation((_graph, nodeId) =>
fromAny<LGraphNode, unknown>({ id: nodeId })
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '7' }])
useDisabledPartnerNodesStore().offenders = [
{
nodeId: fromAny<NodeExecutionId, unknown>('7'),
displayName: 'Selected disabled partner node'
},
{
nodeId: fromAny<NodeExecutionId, unknown>('8'),
displayName: 'Other disabled partner node'
}
]
await nextTick()
expect(groups.allErrorGroups.value).toEqual([
expect.objectContaining({ type: 'disabled_node', count: 2 })
])
expect(groups.errorNodeCount.value).toBe(2)
expect(groups.selectionMatchedGroupKeys.value).toEqual(
new Set(['disabled_node'])
)
expect(groups.selectionErrorCount.value).toBe(1)
})
it('includes missing_node group when missing nodes exist', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
@@ -493,6 +531,47 @@ describe('useErrorGroups', () => {
)
})
it('groups lifted boundary errors under the host node card', async () => {
const { store, groups } = createErrorGroups()
const { rootGraph, host } = createBoundaryLinkedSubgraph({
interiorType: 'InteriorClass'
})
const { getNodeByExecutionId: actualGetNodeByExecutionId } =
await vi.importActual<typeof GraphTraversalUtil>(
'@/utils/graphTraversalUtil'
)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
return actualGetNodeByExecutionId(rootGraph, String(nodeId))
})
store.lastNodeErrors = {
'12:5': nodeError(
[
validationError(
'required_input_missing',
'seed_input',
{},
'Required input is missing'
)
],
'InteriorClass'
)
}
await nextTick()
const execGroup = groups.allErrorGroups.value.find(
(g) => g.type === 'execution'
)
expect(execGroup?.type).toBe('execution')
if (execGroup?.type !== 'execution') return
const card = execGroup.cards[0]
expect(card.nodeId).toBe('12')
expect(card.title).toBe(host.title)
expect(card.errors[0].displayDetails).toBe(
`${host.title} is missing a required input: seed`
)
})
it('groups node validation errors by catalog id across node types', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {

View File

@@ -5,6 +5,7 @@ import type { IFuseOptions } from 'fuse.js'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useComfyRegistryStore } from '@/stores/comfyRegistryStore'
@@ -20,7 +21,7 @@ import {
} from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { st } from '@/i18n'
import { st, t } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import { shouldRenderExecutionItemList } from './executionItemList'
@@ -236,6 +237,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
const missingNodesStore = useMissingNodesErrorStore()
const missingModelStore = useMissingModelStore()
const missingMediaStore = useMissingMediaStore()
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
const canvasStore = useCanvasStore()
const { inferPackFromNodeName } = useComfyRegistryStore()
const collapseState = reactive<Record<string, boolean>>({})
@@ -382,10 +384,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
groupsMap: Map<string, GroupEntry>,
filterBySelection = false
) {
if (!executionErrorStore.lastNodeErrors) return
if (!executionErrorStore.surfacedNodeErrors) return
for (const [rawNodeId, nodeError] of Object.entries(
executionErrorStore.lastNodeErrors
executionErrorStore.surfacedNodeErrors
)) {
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
if (!nodeId) continue
@@ -647,6 +649,31 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return groups.sort((a, b) => a.priority - b.priority)
}
const filteredDisabledNodes = computed(() => {
const all = disabledPartnerNodesStore.offenders
if (!selectedNodeInfo.value.nodeIds) return all
return all.filter((offender) => isAssetErrorInSelection(offender.nodeId))
})
function buildDisabledNodeGroups(
offenders: typeof disabledPartnerNodesStore.offenders
): ErrorGroup[] {
if (!offenders.length) return []
return [
{
type: 'disabled_node' as const,
groupKey: 'disabled_node',
count: offenders.length,
priority: 0,
displayTitle: t('rightSidePanel.disabledNodes.title', offenders.length),
displayMessage: t(
'rightSidePanel.disabledNodes.message',
offenders.length
)
}
]
}
const missingModelGroups = computed<MissingModelGroup[]>(() => {
return groupMissingModelCandidates(
missingModelStore.missingModelCandidates,
@@ -797,6 +824,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
processExecutionError(groupsMap)
return [
...buildDisabledNodeGroups(disabledPartnerNodesStore.offenders),
...buildMissingNodeGroups(),
...buildMissingModelGroups(),
...buildMissingMediaGroups(),
@@ -819,6 +847,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
processExecutionError(groupsMap, true)
return [
...buildDisabledNodeGroups(filteredDisabledNodes.value),
...buildMissingNodeGroups((nodeTypes) =>
someNodeTypeInSelection(nodeTypes, selectionMatchedAssetNodeIds.value)
),
@@ -886,7 +915,14 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
.map((card) => card.nodeId)
.filter((nodeId) => nodeId != null)
return new Set([...executionNodeIds, ...assetNodeIdsWithError.value]).size
const disabledNodeIds = disabledPartnerNodesStore.offenders.map(
(offender) => offender.nodeId
)
return new Set([
...executionNodeIds,
...assetNodeIdsWithError.value,
...disabledNodeIds
]).size
})
const filteredGroups = computed<ErrorGroup[]>(() => {

View File

@@ -2,6 +2,16 @@ import { render, screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mockIsNodeDefDisabled = vi.hoisted(() =>
vi.fn<(nodeDef: ComfyNodeDefImpl) => boolean>(() => false)
)
vi.mock('@/platform/workspace/stores/disabledPartnerNodesStore', () => ({
useDisabledPartnerNodesStore: () => ({
isNodeDefDisabled: mockIsNodeDefDisabled
})
}))
import NodeSearchContent from '@/components/searchbox/v2/NodeSearchContent.vue'
import {
createMockNodeDef,
@@ -24,6 +34,8 @@ describe('NodeSearchContent', () => {
beforeEach(() => {
setupTestPinia()
vi.restoreAllMocks()
mockIsNodeDefDisabled.mockReset()
mockIsNodeDefDisabled.mockReturnValue(false)
setViewport(DESKTOP_VIEWPORT)
const settings = useSettingStore()
settings.settingValues['Comfy.NodeLibrary.Bookmarks.V2'] = []
@@ -315,6 +327,93 @@ describe('NodeSearchContent', () => {
})
describe('search and category interaction', () => {
it('does not report disabled matches in the default empty state', async () => {
const nodeDefStore = useNodeDefStore()
nodeDefStore.updateNodeDefs([
createMockNodeDef({
name: 'BlockedPartnerNode',
display_name: 'Blocked Partner Node',
api_node: true
})
])
mockIsNodeDefDisabled.mockReturnValue(true)
nodeDefStore.registerNodeDefFilter({
id: 'test.disabled-partner-nodes',
name: 'Disabled partner nodes',
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
})
renderComponent()
expect(await screen.findByText('No Results')).toBeInTheDocument()
expect(
screen.queryByText('This node has been disabled by your team admin.')
).not.toBeInTheDocument()
})
it('explains when a query only matches an admin-disabled node', async () => {
const nodeDefStore = useNodeDefStore()
nodeDefStore.updateNodeDefs([
createMockNodeDef({
name: 'BlockedPartnerNode',
display_name: 'Blocked Partner Node',
api_node: true
})
])
mockIsNodeDefDisabled.mockImplementation(
(nodeDef: ComfyNodeDefImpl) => nodeDef.name === 'BlockedPartnerNode'
)
nodeDefStore.registerNodeDefFilter({
id: 'test.disabled-partner-nodes',
name: 'Disabled partner nodes',
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
})
const { user } = renderComponent()
await user.type(screen.getByRole('combobox'), 'Blocked Partner')
expect(
await screen.findByText(
'This node has been disabled by your team admin.'
)
).toBeInTheDocument()
expect(screen.queryByRole('option')).not.toBeInTheDocument()
})
it('does not report a disabled match outside the selected category', async () => {
const nodeDefStore = useNodeDefStore()
nodeDefStore.updateNodeDefs([
createMockNodeDef({
name: 'BlockedPartnerNode',
display_name: 'Blocked Partner Node',
category: 'loaders',
api_node: true
}),
createMockNodeDef({
name: 'SamplerNode',
display_name: 'Sampler Node',
category: 'sampling'
})
])
mockIsNodeDefDisabled.mockImplementation(
(nodeDef: ComfyNodeDefImpl) => nodeDef.name === 'BlockedPartnerNode'
)
nodeDefStore.registerNodeDefFilter({
id: 'test.disabled-partner-nodes',
name: 'Disabled partner nodes',
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
})
const { user } = renderComponent()
await user.click(await screen.findByTestId('category-sampling'))
await user.type(screen.getByRole('combobox'), 'Blocked Partner')
expect(await screen.findByText('No Results')).toBeInTheDocument()
expect(
screen.queryByText('This node has been disabled by your team admin.')
).not.toBeInTheDocument()
})
it('should search within selected category', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({
@@ -757,6 +856,82 @@ describe('NodeSearchContent', () => {
})
describe('rootFilter + category + search combination', () => {
it('counts disabled nodes only in the selected category without a query', async () => {
const nodeDefStore = useNodeDefStore()
const nodeDefs = [
createMockNodeDef({
name: 'CustomSampler',
display_name: 'Custom Sampler',
category: 'sampling',
python_module: 'custom_nodes.my_extension'
}),
createMockNodeDef({
name: 'CustomLoader',
display_name: 'Custom Loader',
category: 'loaders',
python_module: 'custom_nodes.my_extension'
})
]
nodeDefStore.updateNodeDefs(nodeDefs)
const { user } = renderComponent()
await clickFilterBarButton(user, 'Extensions')
await user.click(await screen.findByTestId('category-custom/sampling'))
mockIsNodeDefDisabled.mockReturnValue(true)
nodeDefStore.registerNodeDefFilter({
id: 'test.disabled-partner-nodes',
name: 'Disabled partner nodes',
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
})
nodeDefStore.updateNodeDefs(nodeDefs)
expect(
await screen.findByText(
'This node has been disabled by your team admin.'
)
).toBeInTheDocument()
})
it('ignores disabled matches outside the selected category', async () => {
const nodeDefStore = useNodeDefStore()
const nodeDefs = [
createMockNodeDef({
name: 'CustomSampler',
display_name: 'Custom Sampler',
category: 'sampling',
python_module: 'custom_nodes.my_extension'
}),
createMockNodeDef({
name: 'CustomLoader',
display_name: 'Custom Loader',
category: 'loaders',
python_module: 'custom_nodes.my_extension'
})
]
nodeDefStore.updateNodeDefs(nodeDefs)
const { user } = renderComponent()
await clickFilterBarButton(user, 'Extensions')
await user.click(await screen.findByTestId('category-custom/sampling'))
mockIsNodeDefDisabled.mockImplementation(
(nodeDef) => nodeDef.name === 'CustomLoader'
)
nodeDefStore.registerNodeDefFilter({
id: 'test.disabled-partner-nodes',
name: 'Disabled partner nodes',
predicate: (nodeDef) => !mockIsNodeDefDisabled(nodeDef)
})
nodeDefStore.updateNodeDefs(nodeDefs)
await user.type(screen.getByRole('combobox'), 'Loader')
expect(await screen.findByText('No Results')).toBeInTheDocument()
expect(
screen.queryByText('This node has been disabled by your team admin.')
).not.toBeInTheDocument()
})
it('should intersect rootFilter, selected category, and search query', async () => {
useNodeDefStore().updateNodeDefs([
createMockNodeDef({

View File

@@ -99,7 +99,11 @@
data-testid="no-results"
class="px-4 py-8 text-center text-muted-foreground"
>
{{ $t('g.noResults') }}
{{
disabledMatchCount > 0
? $t('nodeSearch.disabledByTeamAdmin', disabledMatchCount)
: $t('g.noResults')
}}
</div>
</div>
</div>
@@ -121,11 +125,12 @@ import NodeSearchInput from '@/components/searchbox/v2/NodeSearchInput.vue'
import NodeSearchListItem from '@/components/searchbox/v2/NodeSearchListItem.vue'
import { RootCategory } from '@/components/searchbox/v2/rootCategories'
import type { RootCategoryId } from '@/components/searchbox/v2/rootCategories'
import { useDisabledNodeSearch } from '@/composables/node/useDisabledNodeSearch'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import {
BLUEPRINT_CATEGORY,
isCustomNode,
@@ -158,6 +163,7 @@ const { flags } = useFeatureFlags()
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const { disabledNodeDefs, disabledSearchService } = useDisabledNodeSearch()
const nodeAvailability = computed(() => {
let essential = false
@@ -216,21 +222,28 @@ const rootFilterLabel = computed(() => {
}
})
function rootFilterPredicate(
root: RootCategoryId
): (n: ComfyNodeDefImpl) => boolean {
const sourceFilter = sourceCategoryFilters[root]
if (sourceFilter) return sourceFilter
switch (root) {
case RootCategory.Favorites:
return (n) => nodeBookmarkStore.isBookmarked(n)
case RootCategory.Blueprint:
return (n) => n.category.startsWith(BLUEPRINT_CATEGORY)
case RootCategory.PartnerNodes:
return (n) => n.api_node
default:
return () => true
}
}
const rootFilteredNodeDefs = computed(() => {
if (!rootFilter.value) return nodeDefStore.visibleNodeDefs
const allNodes = nodeDefStore.visibleNodeDefs
const sourceFilter = sourceCategoryFilters[rootFilter.value]
if (sourceFilter) return allNodes.filter(sourceFilter)
switch (rootFilter.value) {
case RootCategory.Favorites:
return allNodes.filter((n) => nodeBookmarkStore.isBookmarked(n))
case RootCategory.Blueprint:
return allNodes.filter((n) => n.category.startsWith(BLUEPRINT_CATEGORY))
case RootCategory.PartnerNodes:
return allNodes.filter((n) => n.api_node)
default:
return allNodes
}
return nodeDefStore.visibleNodeDefs.filter(
rootFilterPredicate(rootFilter.value)
)
})
function onToggleFilter(
@@ -311,6 +324,15 @@ function getCategoryResults(baseNodes: ComfyNodeDefImpl[], category: string) {
})
}
function filterBySelectedCategory(baseNodes: ComfyNodeDefImpl[]) {
const category = selectedCategory.value
if (category === DEFAULT_CATEGORY) return baseNodes
const sourceFilter = sourceCategoryFilters[category]
return sourceFilter
? baseNodes.filter(sourceFilter)
: getCategoryResults(baseNodes, category)
}
const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
const baseNodes = rootFilteredNodeDefs.value
const category = selectedCategory.value
@@ -330,10 +352,31 @@ const displayedResults = computed<ComfyNodeDefImpl[]>(() => {
} else {
source = baseNodes
}
return filterBySelectedCategory(source)
})
const sourceFilter = sourceCategoryFilters[category]
if (sourceFilter) return source.filter(sourceFilter)
return getCategoryResults(source, category)
const disabledMatchCount = computed(() => {
if (displayedResults.value.length > 0) return 0
if (disabledNodeDefs.value.length === 0) return 0
const inRoot = rootFilter.value
? disabledNodeDefs.value.filter(rootFilterPredicate(rootFilter.value))
: disabledNodeDefs.value
if (!searchQuery.value && filters.length === 0) {
if (!rootFilter.value && selectedCategory.value === DEFAULT_CATEGORY) {
return 0
}
return filterBySelectedCategory(inRoot).length
}
const matched = disabledSearchService.value.searchNode(
searchQuery.value,
filters,
{ limit: 64 }
)
if (!rootFilter.value) return filterBySelectedCategory(matched).length
const inRootNames = new Set(inRoot.map((n) => n.name))
return filterBySelectedCategory(
matched.filter((n) => inRootNames.has(n.name))
).length
})
const hoveredNodeDef = computed(

View File

@@ -51,7 +51,7 @@
>
<i
aria-hidden="true"
class="icon-[lucide--component] size-3 text-amber-400"
class="icon-[lucide--coins] size-3 text-amber-400"
/>
<i
aria-hidden="true"

View File

@@ -96,9 +96,11 @@
class="flex min-h-0 flex-1 items-center justify-center px-6 py-8 text-center text-sm text-muted-foreground"
>
{{
$t('sideToolbar.nodeLibraryTab.noMatchingNodes', {
query: searchQuery
})
disabledMatchCount > 0
? $t('nodeSearch.disabledByTeamAdmin', disabledMatchCount)
: $t('sideToolbar.nodeLibraryTab.noMatchingNodes', {
query: searchQuery
})
}}
</div>
<AllNodesPanel
@@ -139,6 +141,7 @@ import TabPanel from '@/components/tab/TabPanel.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useDisabledNodeSearch } from '@/composables/node/useDisabledNodeSearch'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import { usePerTabState } from '@/composables/usePerTabState'
import { ESSENTIAL_SECTIONS } from '@/constants/essentialsNodes'
@@ -277,6 +280,17 @@ const hasNoMatches = computed(
() => searchQuery.value.length > 0 && filteredNodeDefs.value.length === 0
)
const { disabledNodeDefs, disabledSearchService } = useDisabledNodeSearch()
const disabledMatchCount = computed(() => {
if (!hasNoMatches.value || disabledNodeDefs.value.length === 0) return 0
return disabledSearchService.value.searchNode(
searchQuery.value,
[],
{ limit: 64 },
{ matchWildcards: false }
).length
})
const sections = computed(() => {
return nodeOrganizationService.organizeNodesTab(activeNodes.value)
})

View File

@@ -1,5 +1,27 @@
<template>
<Toast />
<Toast group="disabled-nodes" position="top-right">
<template #message="slotProps">
<div class="flex min-w-0 flex-1 flex-col gap-2">
<span class="text-sm font-semibold">
{{ slotProps.message.summary }}
</span>
<span class="text-sm text-muted-foreground">
{{ slotProps.message.detail }}
</span>
<div class="flex justify-end">
<Button
v-if="canViewErrors"
size="sm"
variant="secondary"
@click="viewDisabledNodeDetails(slotProps.message)"
>
{{ $t('rightSidePanel.disabledNodes.viewDetails') }}
</Button>
</div>
</div>
</template>
</Toast>
<Toast group="billing-operation" position="top-right">
<template #message="slotProps">
<div class="flex items-center gap-2">
@@ -12,15 +34,28 @@
<script setup lang="ts">
import Toast from 'primevue/toast'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { nextTick, watch } from 'vue'
import { computed, nextTick, watch } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
const toast = useToast()
const toastStore = useToastStore()
const settingStore = useSettingStore()
const canViewErrors = computed(
() =>
settingStore.get('Comfy.UseNewMenu') !== 'Disabled' &&
settingStore.get('Comfy.RightSidePanel.ShowErrorsTab')
)
function viewDisabledNodeDetails(message: ToastMessageOptions) {
useRightSidePanelStore().openPanel('errors')
toast.remove(message)
}
watch(
() => toastStore.messagesToAdd,

View File

@@ -31,7 +31,7 @@
<!-- Credits Section -->
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--coins] size-4 text-amber-400" />
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance

View File

@@ -0,0 +1,38 @@
<template>
<CheckboxRoot
v-bind="forwardedProps"
v-model="checked"
:class="
cn(
'peer flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-[4px] border border-interface-stroke bg-transparent transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-white data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-white',
className
)
"
>
<CheckboxIndicator class="flex items-center justify-center">
<i
:class="
checked === 'indeterminate'
? 'icon-[lucide--minus] size-3'
: 'icon-[lucide--check] size-3'
"
/>
</CheckboxIndicator>
</CheckboxRoot>
</template>
<script setup lang="ts">
import type { CheckboxRootProps } from 'reka-ui'
import { CheckboxIndicator, CheckboxRoot, useForwardProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
type Props = Omit<CheckboxRootProps, 'defaultValue' | 'modelValue'> & {
class?: HTMLAttributes['class']
}
const { class: className, ...restProps } = defineProps<Props>()
const forwardedProps = useForwardProps(restProps)
const checked = defineModel<boolean | 'indeterminate'>({ default: false })
</script>

View File

@@ -0,0 +1,30 @@
<template>
<SwitchRoot
v-model="checked"
:disabled
:class="
cn(
'inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent px-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-primary' : 'bg-interface-stroke'
)
"
>
<SwitchThumb
:class="
cn(
'pointer-events-none block size-4 rounded-full bg-white shadow-sm transition-transform',
checked ? 'translate-x-3.5' : 'translate-x-0'
)
"
/>
</SwitchRoot>
</template>
<script setup lang="ts">
import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
const { disabled = false } = defineProps<{ disabled?: boolean }>()
const checked = defineModel<boolean>({ default: false })
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div :class="cn('relative w-full overflow-auto', className)">
<table
class="w-full caption-bottom border-separate border-spacing-0 text-sm"
>
<slot />
</table>
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<tbody :class="cn('[&_tr:last-child]:border-0', className)">
<slot />
</tbody>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<td :class="cn('px-2 py-2.5 align-middle whitespace-nowrap', className)">
<slot />
</td>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,21 @@
<template>
<th
scope="col"
:class="
cn(
'h-10 px-2 text-left align-middle text-sm font-normal whitespace-nowrap text-muted-foreground',
className
)
"
>
<slot />
</th>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,15 @@
<template>
<thead
:class="cn('[&_tr]:border-b [&_tr]:border-interface-stroke/60', className)"
>
<slot />
</thead>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -0,0 +1,20 @@
<template>
<tr
:class="
cn(
'border-b border-interface-stroke/60 transition-colors hover:bg-secondary-background/50 data-[state=selected]:bg-secondary-background/50',
className
)
"
>
<slot />
</tr>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
</script>

View File

@@ -14,7 +14,12 @@
>
<header
data-component-id="LeftPanelHeader"
class="flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6"
:class="
cn(
'flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6',
headerHeightClass
)
"
>
<slot name="leftPanelHeaderTitle" />
<Button
@@ -33,7 +38,12 @@
<div class="flex flex-col overflow-hidden bg-base-background">
<header
v-if="$slots.header"
class="flex h-18 w-full items-center justify-between gap-2 px-6"
:class="
cn(
'flex h-18 w-full items-center justify-between gap-2 px-6',
headerHeightClass
)
"
>
<div class="flex min-w-0 flex-1 gap-2">
<Button
@@ -151,20 +161,22 @@ const SIZE_CLASSES = {
} as const
type ModalSize = keyof typeof SIZE_CLASSES
type ContentPadding = 'default' | 'compact' | 'none'
type ContentPadding = 'default' | 'compact' | 'none' | 'flush'
const {
contentTitle,
rightPanelTitle,
size = 'lg',
leftPanelWidth = '14rem',
contentPadding = 'default'
contentPadding = 'default',
headerHeightClass = 'h-18'
} = defineProps<{
contentTitle: string
rightPanelTitle?: string
size?: ModalSize
leftPanelWidth?: string
contentPadding?: ContentPadding
headerHeightClass?: string
}>()
const sizeClasses = computed(() => SIZE_CLASSES[size])
@@ -204,7 +216,10 @@ const contentContainerClass = computed(() =>
cn(
'flex scrollbar-custom min-h-0 flex-1 flex-col overflow-y-auto',
contentPadding === 'default' && 'px-6 pt-0 pb-10',
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
contentPadding === 'compact' && 'px-6 pt-0 pb-2',
// Keep the horizontal inset but let content run to the bottom edge (it
// clips there instead of ending above a padding gap).
contentPadding === 'flush' && 'px-6 pt-0'
)
)

View File

@@ -107,6 +107,8 @@ export interface BillingState {
export interface BillingContext extends BillingState, BillingActions {
type: ComputedRef<BillingType>
/** Subscription paused on a failed payment (`subscriptionStatus === 'paused'`). */
isPaused: ComputedRef<boolean>
/**
* True when the active team workspace is still on a pre-credit-slider
* (legacy) per-member tier plan, which keeps the old team pricing table.

View File

@@ -147,6 +147,7 @@ function useBillingContextInternal(): BillingContext {
const subscriptionStatus = computed(() =>
toValue(activeContext.value.subscriptionStatus)
)
const isPaused = computed(() => subscriptionStatus.value === 'paused')
const tier = computed(() => toValue(activeContext.value.tier))
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
@@ -301,6 +302,7 @@ function useBillingContextInternal(): BillingContext {
isLegacyTeamPlan,
billingStatus,
subscriptionStatus,
isPaused,
tier,
renewalDate,
getMaxSeats,

View File

@@ -4,6 +4,7 @@ import { computed, watch } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import type { NodeError } from '@/schemas/apiSchema'
@@ -34,7 +35,8 @@ function reconcileNodeErrorFlags(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError> | null,
missingModelExecIds: Set<string>,
missingMediaExecIds: Set<string> = new Set()
missingMediaExecIds: Set<string> = new Set(),
disabledNodeExecIds: Set<string> = new Set()
): void {
// Collect nodes and slot info that should be flagged
// Includes both error-owning nodes and their ancestor containers
@@ -71,6 +73,11 @@ function reconcileNodeErrorFlags(
if (node) flaggedNodes.add(node)
}
for (const execId of disabledNodeExecIds) {
const node = getNodeByExecutionId(rootGraph, execId)
if (node) flaggedNodes.add(node)
}
forEachNode(rootGraph, (node) => {
setNodeHasErrors(node, flaggedNodes.has(node))
@@ -84,9 +91,10 @@ function reconcileNodeErrorFlags(
}
export function useNodeErrorFlagSync(
lastNodeErrors: Ref<Record<string, NodeError> | null>,
nodeErrors: Ref<Record<string, NodeError> | null>,
missingModelStore: ReturnType<typeof useMissingModelStore>,
missingMediaStore: ReturnType<typeof useMissingMediaStore>
missingMediaStore: ReturnType<typeof useMissingMediaStore>,
disabledPartnerNodesStore: ReturnType<typeof useDisabledPartnerNodesStore>
): () => void {
const settingStore = useSettingStore()
const showErrorsTab = computed(() =>
@@ -95,9 +103,10 @@ export function useNodeErrorFlagSync(
const stop = watch(
[
lastNodeErrors,
nodeErrors,
() => missingModelStore.missingModelNodeIds,
() => missingMediaStore.missingMediaNodeIds,
() => disabledPartnerNodesStore.disabledAncestorExecutionIds,
showErrorsTab
],
() => {
@@ -108,12 +117,15 @@ export function useNodeErrorFlagSync(
// Vue nodes compute hasAnyError independently and are unaffected.
reconcileNodeErrorFlags(
app.rootGraph,
lastNodeErrors.value,
nodeErrors.value,
showErrorsTab.value
? missingModelStore.missingModelAncestorExecutionIds
: new Set(),
showErrorsTab.value
? missingMediaStore.missingMediaAncestorExecutionIds
: new Set(),
showErrorsTab.value
? disabledPartnerNodesStore.disabledAncestorExecutionIds
: new Set()
)
},

View File

@@ -0,0 +1,20 @@
import { computed } from 'vue'
import { useDisabledPartnerNodesStore } from '@/platform/workspace/stores/disabledPartnerNodesStore'
import { NodeSearchService } from '@/services/nodeSearchService'
import { useNodeDefStore } from '@/stores/nodeDefStore'
export function useDisabledNodeSearch() {
const nodeDefStore = useNodeDefStore()
const disabledPartnerNodesStore = useDisabledPartnerNodesStore()
const disabledNodeDefs = computed(() =>
Object.values(nodeDefStore.nodeDefsByName).filter((nodeDef) =>
disabledPartnerNodesStore.isNodeDefDisabled(nodeDef)
)
)
const disabledSearchService = computed(
() => new NodeSearchService(disabledNodeDefs.value)
)
return { disabledNodeDefs, disabledSearchService }
}

View File

@@ -90,7 +90,9 @@ export function useExternalLink() {
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
githubElectron: 'https://github.com/Comfy-Org/electron',
forum: 'https://forum.comfy.org/',
comfyOrg: 'https://www.comfy.org/'
comfyOrg: 'https://www.comfy.org/',
teamPlanRequests:
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests'
}
/** Common doc paths for use with buildDocsUrl */

View File

@@ -24,6 +24,7 @@ export enum ServerFeatureFlag {
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
PARTNER_NODE_GOVERNANCE_ENABLED = 'partner_node_governance_enabled',
USER_SECRETS_ENABLED = 'user_secrets_enabled',
NODE_REPLACEMENTS = 'node_replacements',
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
@@ -133,6 +134,13 @@ export function useFeatureFlags() {
cachedTeamWorkspacesEnabled
)
},
get partnerNodeGovernanceEnabled() {
return resolveFlag(
ServerFeatureFlag.PARTNER_NODE_GOVERNANCE_ENABLED,
remoteConfig.value.partner_node_governance_enabled,
false
)
},
get userSecretsEnabled() {
return resolveFlag(
ServerFeatureFlag.USER_SECRETS_ENABLED,

View File

@@ -11,7 +11,9 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useViewErrorsInGraph } from './useViewErrorsInGraph'
const apiMock = vi.hoisted(() => ({
addEventListener: vi.fn(),
getSettings: vi.fn(),
removeEventListener: vi.fn(),
storeSetting: vi.fn(),
storeSettings: vi.fn()
}))

View File

@@ -0,0 +1,299 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import { nodeError, validationError } from '@/utils/__tests__/nodeErrorHelpers'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createBoundaryLinkedSubgraph,
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { toNodeId } from '@/types/nodeId'
import { liftNodeErrorsToBoundary } from './liftNodeErrorsToBoundary'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('liftNodeErrorsToBoundary', () => {
it('lifts a boundary-linked slot error to the host', () => {
const { host, rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result).toEqual({
'12': {
class_type: host.title,
dependent_outputs: [],
errors: [
expect.objectContaining({
type: 'required_input_missing',
extra_info: expect.objectContaining({
input_name: 'seed',
source_execution_id: '12:5',
source_input_name: 'seed_input'
})
})
]
}
})
})
it('lifts a promoted-widget value error to the host input', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const interior = new LGraphNode('CheckpointLoaderSimple')
interior.id = toNodeId(5)
const input = interior.addInput('ckpt_name', 'COMBO')
const widget = interior.addWidget('combo', 'ckpt_name', '', () => {}, {
values: ['present.safetensors']
})
input.widget = { name: widget.name }
subgraph.add(interior)
expect(promoteValueWidgetViaSubgraphInput(host, interior, widget).ok).toBe(
true
)
const result = liftNodeErrorsToBoundary(rootGraph, {
'12:5': nodeError([
validationError('value_not_in_list', 'ckpt_name', {
received_value: 'missing.safetensors',
input_config: ['COMBO', { values: ['present.safetensors'] }]
})
])
})
expect(result['12'].errors[0].extra_info).toMatchObject({
input_name: 'ckpt_name',
source_execution_id: '12:5',
source_input_name: 'ckpt_name',
received_value: 'missing.safetensors',
input_config: ['COMBO', { values: ['present.safetensors'] }]
})
})
it('recurses through nested boundary-linked hosts', () => {
const rootGraph = createTestRootGraph()
const outerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
outerHost.title = 'Outer Host'
rootGraph.add(outerHost)
const middleSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const middleHost = createTestSubgraphNode(middleSubgraph, {
id: 2,
parentGraph: outerSubgraph
})
outerSubgraph.add(middleHost)
outerSubgraph.inputNode.slots[0].connect(middleHost.inputs[0], middleHost)
const leaf = new LGraphNode('LeafNode')
leaf.id = toNodeId(3)
const leafInput = leaf.addInput('seed_input', '*')
middleSubgraph.add(leaf)
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
const result = liftNodeErrorsToBoundary(rootGraph, {
'1:2:3': nodeError([
validationError('required_input_missing', 'seed_input')
])
})
expect(Object.keys(result)).toEqual(['1'])
expect(result['1'].class_type).toBe(outerHost.title)
expect(result['1'].errors[0].extra_info).toMatchObject({
input_name: 'seed',
source_execution_id: '1:2:3',
source_input_name: 'seed_input'
})
})
it('keeps errors on ordinary interior data-flow links', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const source = new LGraphNode('SourceNode')
source.id = toNodeId(4)
source.addOutput('seed', '*')
subgraph.add(source)
const target = new LGraphNode('TargetNode')
target.id = toNodeId(5)
target.addInput('seed_input', '*')
subgraph.add(target)
source.connect(0, target, 0)
const errors = {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('keeps errors without a liftable subject on the interior node', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('required_input_missing'),
validationError('exception_during_validation', 'seed_input'),
validationError('dependency_cycle', 'seed_input'),
validationError(
'custom_validation_failed',
'seed_input',
{ received_value: 'image.png' },
'Invalid image file'
)
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('keeps unknown typed validation errors on the interior node', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12:5': nodeError([
validationError('future_backend_validation_type', 'seed_input')
])
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('splits liftable and non-liftable errors from the same node entry', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const result = liftNodeErrorsToBoundary(rootGraph, {
'12:5': nodeError([
validationError('required_input_missing', 'seed_input'),
validationError('exception_during_validation', 'seed_input')
])
})
expect(result['12'].errors).toHaveLength(1)
expect(result['12'].errors[0].type).toBe('required_input_missing')
expect(result['12:5'].errors).toHaveLength(1)
expect(result['12:5'].errors[0].type).toBe('exception_during_validation')
})
it('merges a lifted error into an existing host entry', () => {
const { rootGraph } = createBoundaryLinkedSubgraph()
const errors = {
'12': {
class_type: 'ExistingHostClass',
dependent_outputs: ['existing-output'],
errors: [validationError('value_smaller_than_min', 'other')]
},
'12:5': nodeError([
validationError('required_input_missing', 'seed_input')
])
}
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result['12']).toMatchObject({
class_type: 'ExistingHostClass',
dependent_outputs: ['existing-output']
})
expect(result['12'].errors.map((error) => error.type)).toEqual([
'value_smaller_than_min',
'required_input_missing'
])
})
it('keeps own errors before lifted errors for nested host keys', () => {
const rootGraph = createTestRootGraph()
const outerSubgraph = createTestSubgraph({ rootGraph })
const outerHost = createTestSubgraphNode(outerSubgraph, { id: 1 })
rootGraph.add(outerHost)
const middleSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: '*' }]
})
const middleHost = createTestSubgraphNode(middleSubgraph, {
id: 2,
parentGraph: outerSubgraph
})
outerSubgraph.add(middleHost)
const leaf = new LGraphNode('LeafNode')
leaf.id = toNodeId(3)
const leafInput = leaf.addInput('seed_input', '*')
middleSubgraph.add(leaf)
middleSubgraph.inputNode.slots[0].connect(leafInput, leaf)
const result = liftNodeErrorsToBoundary(rootGraph, {
'1:2:3': nodeError([
validationError('required_input_missing', 'seed_input')
]),
'1:2': nodeError([validationError('value_smaller_than_min', 'seed')])
})
expect(result['1:2'].errors.map((error) => error.type)).toEqual([
'value_smaller_than_min',
'required_input_missing'
])
})
it('preserves empty error entries unchanged', () => {
const rootGraph = createTestRootGraph()
const errors = {
'12': nodeError([], 'ExtraRootNode')
}
expect(liftNodeErrorsToBoundary(rootGraph, errors)).toEqual(errors)
})
it('fails open without mutating the input record', () => {
const rootGraph = createTestRootGraph()
const subgraph = createTestSubgraph({ rootGraph })
const host = createTestSubgraphNode(subgraph, { id: 12 })
rootGraph.add(host)
const interior = new LGraphNode('InteriorNode')
interior.id = toNodeId(5)
interior.addInput('unlinked', '*')
subgraph.add(interior)
const errors = {
'99:5': nodeError([validationError('required_input_missing', 'x')]),
'12:5': nodeError([
validationError('required_input_missing', 'missing'),
validationError('value_not_in_list', 'unlinked')
])
}
const original = structuredClone(errors)
const result = liftNodeErrorsToBoundary(rootGraph, errors)
expect(result).toEqual(original)
expect(errors).toEqual(original)
expect(result).not.toBe(errors)
})
})

View File

@@ -0,0 +1,193 @@
import { groupBy, partition } from 'es-toolkit'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import type { NodeError } from '@/schemas/apiSchema'
import { tryNormalizeNodeExecutionId } from '@/types/nodeIdentification'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { isNodeLevelValidationError } from '@/utils/executionErrorUtil'
import type { NodeValidationError } from '@/utils/executionErrorUtil'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { isSubgraph } from '@/utils/typeGuardUtil'
export interface LiftedErrorExtraInfo {
input_name: string
source_execution_id: string
source_input_name: string
}
export interface LiftedSurface {
hostExecId: NodeExecutionId
hostInputName: string
}
interface ErrorPlacement {
kind: 'own' | 'lifted'
targetExecId: string
error: NodeValidationError
}
export function getLiftedErrorSource(
error: NodeValidationError
): LiftedErrorExtraInfo | null {
const extraInfo = error.extra_info
if (!extraInfo) return null
const { input_name, source_execution_id, source_input_name } = extraInfo
if (
typeof input_name !== 'string' ||
typeof source_execution_id !== 'string' ||
typeof source_input_name !== 'string'
) {
return null
}
return { input_name, source_execution_id, source_input_name }
}
function getHostExecutionId(executionId: string): NodeExecutionId | null {
const separatorIndex = executionId.lastIndexOf(':')
if (separatorIndex <= 0) return null
return tryNormalizeNodeExecutionId(executionId.slice(0, separatorIndex))
}
/**
* Boundary surfaces that expose `(executionId, inputName)`, innermost first.
* Walks one host per level and stops at the last resolvable surface, so an
* unresolvable deeper host falls back to the shallower one (fail-open).
*/
export function resolveLiftChain(
rootGraph: LGraph,
executionId: string,
inputName: string
): LiftedSurface[] {
const chain: LiftedSurface[] = []
let currentExecId = executionId
let currentInputName = inputName
for (;;) {
const node = getNodeByExecutionId(rootGraph, currentExecId)
const graph = node?.graph
if (!node || !graph || !isSubgraph(graph)) break
const slot = node.inputs?.find((input) => input.name === currentInputName)
if (slot?.link == null) break
const subgraphInput = graph
.getLink(slot.link)
?.resolve(graph)?.subgraphInput
if (!subgraphInput) break
const hostExecId = getHostExecutionId(currentExecId)
if (!hostExecId || !getNodeByExecutionId(rootGraph, hostExecId)) break
chain.push({ hostExecId, hostInputName: subgraphInput.name })
currentExecId = hostExecId
currentInputName = subgraphInput.name
}
return chain
}
function createEmptyNodeError(nodeError: NodeError): NodeError {
return {
...nodeError,
errors: []
}
}
// Lifted host entries use the host title for display; SubgraphNode.type is a UUID.
function createLiftedHostEntry(
rootGraph: LGraph,
hostExecId: string
): NodeError {
return {
class_type:
getNodeByExecutionId(rootGraph, hostExecId)?.title ?? hostExecId,
dependent_outputs: [],
errors: []
}
}
function toErrorPlacement(
rootGraph: LGraph,
executionId: string,
error: NodeValidationError
): ErrorPlacement {
const inputName = error.extra_info?.input_name
const surface =
inputName && !isNodeLevelValidationError(error)
? resolveLiftChain(rootGraph, executionId, inputName).at(-1)
: undefined
if (!inputName || !surface) {
return {
kind: 'own',
targetExecId: executionId,
error
}
}
const liftedExtraInfo: LiftedErrorExtraInfo = {
input_name: surface.hostInputName,
source_execution_id: executionId,
source_input_name: inputName
}
return {
kind: 'lifted',
targetExecId: surface.hostExecId,
error: {
...error,
extra_info: {
...error.extra_info,
...liftedExtraInfo
}
}
}
}
export function liftNodeErrorsToBoundary(
rootGraph: LGraph,
nodeErrors: Record<string, NodeError>
): Record<string, NodeError> {
const output: Record<string, NodeError> = {}
const placements = Object.entries(nodeErrors).flatMap(
([executionId, nodeError]) =>
nodeError.errors.map((error) =>
toErrorPlacement(rootGraph, executionId, error)
)
)
for (const [executionId, nodeError] of Object.entries(nodeErrors)) {
if (nodeError.errors.length === 0) {
output[executionId] = createEmptyNodeError(nodeError)
}
}
const placementsByTarget = groupBy(
placements,
(placement) => placement.targetExecId
)
for (const [targetExecId, targetPlacements] of Object.entries(
placementsByTarget
)) {
const baseEntry = nodeErrors[targetExecId]
? createEmptyNodeError(nodeErrors[targetExecId])
: createLiftedHostEntry(rootGraph, targetExecId)
const [ownErrors, liftedErrors] = partition(
targetPlacements,
(placement) => placement.kind === 'own'
)
output[targetExecId] = {
...baseEntry,
errors: [...ownErrors, ...liftedErrors].map(
(placement) => placement.error
)
}
}
return output
}

View File

@@ -3,23 +3,42 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { GizmoManager } from './GizmoManager'
const { mockSetMode, mockAttach, mockDetach, mockGetHelper, mockDispose } =
vi.hoisted(() => ({
mockSetMode: vi.fn(),
mockAttach: vi.fn(),
mockDetach: vi.fn(),
mockGetHelper: vi.fn(),
mockDispose: vi.fn()
}))
const {
mockSetMode,
mockAttach,
mockDetach,
mockGetHelper,
mockDispose,
transformControlsInstances,
omitGetPointer
} = vi.hoisted(() => ({
mockSetMode: vi.fn(),
mockAttach: vi.fn(),
mockDetach: vi.fn(),
mockGetHelper: vi.fn(),
mockDispose: vi.fn(),
transformControlsInstances: [] as unknown[],
omitGetPointer: { value: false }
}))
vi.mock('three/examples/jsm/controls/TransformControls', () => {
class TransformControls {
enabled = true
dragging = false
camera: THREE.Camera
_getPointer?: (event: PointerEvent) => {
x: number
y: number
button: number
}
private listeners = new Map<string, ((e: unknown) => void)[]>()
constructor(camera: THREE.Camera) {
this.camera = camera
if (!omitGetPointer.value) {
this._getPointer = (event) => ({ x: 0, y: 0, button: event.button })
}
transformControlsInstances.push(this)
}
addEventListener(event: string, cb: (e: unknown) => void) {
@@ -64,6 +83,8 @@ describe('GizmoManager', () => {
beforeEach(() => {
vi.clearAllMocks()
transformControlsInstances.length = 0
omitGetPointer.value = false
scene = new THREE.Scene()
interactionElement = document.createElement('div')
@@ -89,6 +110,120 @@ describe('GizmoManager', () => {
vi.restoreAllMocks()
})
describe('setPointerNdcSource', () => {
type PointerNdc = { x: number; y: number; button: number }
function lastControls() {
return transformControlsInstances.at(-1) as {
dragging: boolean
_getPointer?: (event: PointerEvent) => PointerNdc
}
}
function getPointerOverride() {
return lastControls()._getPointer
}
it('routes TransformControls pointer NDC through the injected source', () => {
manager.init()
manager.setPointerNdcSource((clientX, clientY) => ({
x: clientX / 100,
y: clientY / 100,
inside: true
}))
const pointer = getPointerOverride()!({
clientX: 50,
clientY: -25,
button: 2
} as PointerEvent)
expect(pointer).toEqual({ x: 0.5, y: -0.25, button: 2 })
})
it('maps unmappable points to an off-screen pointer', () => {
manager.init()
manager.setPointerNdcSource(() => null)
const pointer = getPointerOverride()!({
clientX: 0,
clientY: 0,
button: 0
} as PointerEvent)
expect(pointer).toEqual({ x: 10, y: 10, button: 0 })
})
it('maps points outside the viewport to an off-screen pointer while not dragging', () => {
manager.init()
manager.setPointerNdcSource(() => ({ x: -1.2, y: 0.4, inside: false }))
const pointer = getPointerOverride()!({
clientX: 0,
clientY: 0,
button: 0
} as PointerEvent)
expect(pointer).toEqual({ x: 10, y: 10, button: 0 })
})
it('keeps the unclamped NDC for points outside the viewport mid-drag', () => {
manager.init()
manager.setPointerNdcSource(() => ({ x: -1.2, y: 0.4, inside: false }))
lastControls().dragging = true
const pointer = getPointerOverride()!({
clientX: 0,
clientY: 0,
button: -1
} as PointerEvent)
expect(pointer).toEqual({ x: -1.2, y: 0.4, button: -1 })
})
it('applies a source registered before init once init runs', () => {
manager.setPointerNdcSource(() => ({ x: 0.5, y: 0.5, inside: true }))
manager.init()
const pointer = getPointerOverride()!({
clientX: 0,
clientY: 0,
button: 1
} as PointerEvent)
expect(pointer).toEqual({ x: 0.5, y: 0.5, button: 1 })
})
it('delegates to the stock mapping until a source is registered', () => {
manager.init()
const stock = getPointerOverride()!({
clientX: 40,
clientY: 60,
button: 2
} as PointerEvent)
expect(stock).toEqual({ x: 0, y: 0, button: 2 })
manager.setPointerNdcSource(() => ({ x: 0.5, y: -0.25, inside: true }))
const mapped = getPointerOverride()!({
clientX: 40,
clientY: 60,
button: 2
} as PointerEvent)
expect(mapped).toEqual({ x: 0.5, y: -0.25, button: 2 })
})
it('warns and skips the override when _getPointer is missing at init', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
omitGetPointer.value = true
manager.setPointerNdcSource(() => ({ x: 0.5, y: 0.5, inside: true }))
manager.init()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('_getPointer'))
expect(lastControls()._getPointer).toBeUndefined()
})
})
describe('init', () => {
it('adds helper to scene with correct name and render order', () => {
manager.init()

View File

@@ -4,6 +4,9 @@ import { TransformControls } from 'three/examples/jsm/controls/TransformControls
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import type { GizmoMode, Model3DTransform } from './interfaces'
import type { PointerNdcSource } from './load3dViewport'
const OFF_SCREEN_POINTER_NDC = { x: 10, y: 10 }
export class GizmoManager {
private transformControls: TransformControls | null = null
@@ -18,6 +21,7 @@ export class GizmoManager {
private interactionElement: HTMLElement
private orbitControls: OrbitControls
private onTransformChange?: () => void
private getPointerNdc?: PointerNdcSource
constructor(
scene: THREE.Scene,
@@ -46,12 +50,45 @@ export class GizmoManager {
}
})
this.installPointerNdcOverride()
const helper = this.transformControls.getHelper()
helper.name = 'GizmoTransformControls'
helper.renderOrder = 999
this.scene.add(helper)
}
setPointerNdcSource(getPointerNdc: PointerNdcSource): void {
this.getPointerNdc = getPointerNdc
}
private installPointerNdcOverride(): void {
if (!this.transformControls) return
const transformControls = this.transformControls
const controls = transformControls as unknown as {
_getPointer?: (event: PointerEvent) => {
x: number
y: number
button: number
}
}
const original = controls._getPointer
if (typeof original !== 'function') {
console.warn(
'TransformControls no longer exposes _getPointer; letterbox-aware gizmo pointer mapping is disabled.'
)
return
}
controls._getPointer = (event: PointerEvent) => {
if (!this.getPointerNdc) return original.call(transformControls, event)
const ndc = this.getPointerNdc(event.clientX, event.clientY)
if (!ndc || (!ndc.inside && !transformControls.dragging)) {
return { ...OFF_SCREEN_POINTER_NDC, button: event.button }
}
return { x: ndc.x, y: ndc.y, button: event.button }
}
}
setupForModel(model: THREE.Object3D): void {
if (!this.transformControls) return

View File

@@ -1,11 +1,13 @@
import * as THREE from 'three'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Load3dDeps } from '@/extensions/core/load3d/Load3d'
import Load3d from '@/extensions/core/load3d/Load3d'
import type {
CameraState,
GizmoMode
} from '@/extensions/core/load3d/interfaces'
import type { PointerNdcSource } from '@/extensions/core/load3d/load3dViewport'
const {
cloneSkinnedMock,
@@ -1260,4 +1262,102 @@ describe('Load3d', () => {
)
})
})
describe('constructor wiring', () => {
function makeConstructorDeps() {
const container = document.createElement('div')
const canvas = document.createElement('canvas')
container.appendChild(canvas)
const view = {
canvas,
renderer: {
setViewport: vi.fn(),
setScissor: vi.fn(),
setScissorTest: vi.fn(),
setClearColor: vi.fn(),
clear: vi.fn(),
render: vi.fn()
},
width: 800,
height: 600,
state: { clearColor: new THREE.Color(0x000000), clearAlpha: 0 },
observeResize: vi.fn(),
beginRender: vi.fn(),
blit: vi.fn(),
setSize: vi.fn(),
dispose: vi.fn()
}
const gizmoManager = {
setPointerNdcSource: vi.fn(),
init: vi.fn(),
dispose: vi.fn()
}
const deps = {
view,
eventManager: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
emitEvent: vi.fn()
},
sceneManager: {
init: vi.fn(),
scene: new THREE.Scene(),
renderBackground: vi.fn(),
handleResize: vi.fn(),
dispose: vi.fn()
},
cameraManager: {
init: vi.fn(),
activeCamera: new THREE.PerspectiveCamera(),
handleResize: vi.fn(),
dispose: vi.fn()
},
controlsManager: { init: vi.fn(), update: vi.fn(), dispose: vi.fn() },
lightingManager: { init: vi.fn(), dispose: vi.fn() },
viewHelperManager: {
createViewHelper: vi.fn(),
init: vi.fn(),
update: vi.fn(),
render: vi.fn(),
dispose: vi.fn()
},
hdriManager: { dispose: vi.fn() },
loaderManager: { init: vi.fn(), dispose: vi.fn() },
modelManager: { dispose: vi.fn() },
recordingManager: {
getIsRecording: vi.fn(() => false),
dispose: vi.fn()
},
animationManager: {
init: vi.fn(),
update: vi.fn(),
isAnimationPlaying: false,
dispose: vi.fn()
},
gizmoManager,
adapterRef: { current: null, capabilities: null }
}
return { container, deps: deps as unknown as Load3dDeps, gizmoManager }
}
it('wires the gizmo pointer NDC source to clientPointToNdc on every construction path', () => {
const { container, deps, gizmoManager } = makeConstructorDeps()
const load3d = new Load3d(container, deps)
expect(gizmoManager.setPointerNdcSource).toHaveBeenCalledOnce()
const ndc = { x: 0.25, y: -0.5, inside: true }
const clientPointToNdc = vi
.spyOn(load3d, 'clientPointToNdc')
.mockReturnValue(ndc)
const source = gizmoManager.setPointerNdcSource.mock
.calls[0][0] as PointerNdcSource
expect(source(12, 34)).toBe(ndc)
expect(clientPointToNdc).toHaveBeenCalledWith(12, 34)
load3d.remove()
})
})
})

View File

@@ -83,6 +83,9 @@ class Load3d extends Viewport3d {
this.loaderManager.init()
this.animationManager.init()
this.gizmoManager.setPointerNdcSource((clientX, clientY) =>
this.clientPointToNdc(clientX, clientY)
)
this.gizmoManager.init()
this.eventManager.addEventListener('modelReady', () => {

View File

@@ -386,6 +386,67 @@ describe('Viewport3d', () => {
})
})
describe('clientPointToNdc', () => {
function installCanvas(rect: {
left: number
top: number
width: number
height: number
}) {
const canvas = document.createElement('canvas')
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
...rect,
right: rect.left + rect.width,
bottom: rect.top + rect.height,
x: rect.left,
y: rect.top,
toJSON: () => ({})
} as DOMRect)
Object.assign(ctx.viewport, { view: { canvas } })
}
beforeEach(() => {
Object.assign(ctx.viewport, {
targetWidth: 100,
targetHeight: 100,
targetAspectRatio: 1,
isViewerMode: false
})
})
it('normalizes client coordinates against the canvas rect before letterbox mapping', () => {
installCanvas({ left: 100, top: 50, width: 400, height: 200 })
expect(ctx.viewport.clientPointToNdc(300, 150)).toEqual({
x: expect.closeTo(0),
y: expect.closeTo(0),
inside: true
})
expect(ctx.viewport.clientPointToNdc(150, 150)).toEqual({
x: expect.closeTo(-1.5),
y: expect.closeTo(0),
inside: false
})
})
it('returns null when the canvas has no layout size', () => {
installCanvas({ left: 0, top: 0, width: 0, height: 0 })
expect(ctx.viewport.clientPointToNdc(10, 10)).toBeNull()
})
it('maps the full canvas when no aspect ratio is maintained', () => {
installCanvas({ left: 100, top: 50, width: 400, height: 200 })
Object.assign(ctx.viewport, { targetWidth: 0, targetHeight: 0 })
expect(ctx.viewport.clientPointToNdc(100, 50)).toEqual({
x: expect.closeTo(-1),
y: expect.closeTo(1),
inside: true
})
})
})
describe('start / remove lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers()

View File

@@ -1,6 +1,7 @@
import * as THREE from 'three'
import type { RendererView } from '@/renderer/three/RendererView'
import { normalize } from '@/utils/mathUtil'
import type { CameraManager } from './CameraManager'
import type { ControlsManager } from './ControlsManager'
@@ -17,7 +18,12 @@ import type {
import { attachContextMenuGuard } from './load3dContextMenuGuard'
import type { RenderLoopHandle } from './load3dRenderLoop'
import { startRenderLoop } from './load3dRenderLoop'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
import type { LetterboxNdc } from './load3dViewport'
import {
clientPointToLetterboxNdc,
computeLetterboxedViewport,
isLoad3dActive
} from './load3dViewport'
const VIEW_HELPER_SIZE = 128
@@ -276,6 +282,17 @@ export class Viewport3d {
this.renderer.render(this.sceneManager.scene, this.getRenderCamera())
}
clientPointToNdc(clientX: number, clientY: number): LetterboxNdc | null {
const rect = this.domElement.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) return null
return clientPointToLetterboxNdc(
normalize(clientX, rect.left, rect.right),
normalize(clientY, rect.top, rect.bottom),
{ width: rect.width, height: rect.height },
this.shouldMaintainAspectRatio() ? this.targetAspectRatio : null
)
}
protected startAnimation(): void {
this.renderLoop = startRenderLoop({
tick: () => {

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { computeLetterboxedViewport, isLoad3dActive } from './load3dViewport'
import {
clientPointToLetterboxNdc,
computeLetterboxedViewport,
isLoad3dActive
} from './load3dViewport'
import type { Load3dActivityFlags } from './load3dViewport'
describe('computeLetterboxedViewport', () => {
@@ -106,3 +110,59 @@ describe('isLoad3dActive', () => {
expect(isLoad3dActive({ ...idle, [flag]: true })).toBe(true)
})
})
describe('clientPointToLetterboxNdc', () => {
function ndc(x: number, y: number, inside = true) {
return { x: expect.closeTo(x), y: expect.closeTo(y), inside }
}
it('maps the full canvas when no target aspect is set', () => {
expect(
clientPointToLetterboxNdc(0.5, 0.5, { width: 400, height: 300 }, null)
).toEqual(ndc(0, 0))
expect(
clientPointToLetterboxNdc(0, 1, { width: 400, height: 300 }, null)
).toEqual(ndc(-1, -1))
})
it('maps pillarboxed content edges to -1/1', () => {
const container = { width: 400, height: 200 }
expect(clientPointToLetterboxNdc(0.25, 0.5, container, 1)).toEqual(
ndc(-1, 0)
)
expect(clientPointToLetterboxNdc(0.75, 0, container, 1)).toEqual(ndc(1, 1))
expect(clientPointToLetterboxNdc(0.5, 0.5, container, 1)).toEqual(ndc(0, 0))
})
it('extrapolates unclamped NDC marked outside on the letterbox bars', () => {
const container = { width: 400, height: 200 }
expect(clientPointToLetterboxNdc(0.1, 0.5, container, 1)).toEqual(
ndc(-1.6, 0, false)
)
expect(clientPointToLetterboxNdc(0.9, 0.5, container, 1)).toEqual(
ndc(1.6, 0, false)
)
})
it('handles letterbox bars above/below wide content', () => {
const container = { width: 200, height: 400 }
expect(clientPointToLetterboxNdc(0.5, 0.375, container, 2)).toEqual(
ndc(0, 1)
)
expect(clientPointToLetterboxNdc(0.5, 0.1, container, 2)).toEqual(
ndc(0, 3.2, false)
)
})
it('returns null instead of NaN for zero-size containers', () => {
expect(
clientPointToLetterboxNdc(0.5, 0.5, { width: 0, height: 0 }, 1)
).toBeNull()
expect(
clientPointToLetterboxNdc(0.5, 0.5, { width: 0, height: 200 }, 1)
).toBeNull()
expect(
clientPointToLetterboxNdc(0.5, 0.5, { width: 400, height: 0 }, 1)
).toBeNull()
})
})

View File

@@ -1,3 +1,5 @@
import { denormalize, normalize } from '@/utils/mathUtil'
type Size = { width: number; height: number }
type LetterboxedViewport = {
@@ -34,6 +36,39 @@ export function computeLetterboxedViewport(
}
}
export type LetterboxNdc = { x: number; y: number; inside: boolean }
export type PointerNdcSource = (
clientX: number,
clientY: number
) => LetterboxNdc | null
export function clientPointToLetterboxNdc(
normalizedX: number,
normalizedY: number,
container: Size,
targetAspectRatio: number | null
): LetterboxNdc | null {
const toNdc = (localX: number, localY: number): LetterboxNdc => ({
x: denormalize(localX, -1, 1),
y: -denormalize(localY, -1, 1),
inside: localX >= 0 && localX <= 1 && localY >= 0 && localY <= 1
})
if (targetAspectRatio === null) {
return toNdc(normalizedX, normalizedY)
}
const { offsetX, offsetY, width, height } = computeLetterboxedViewport(
container,
targetAspectRatio
)
if (width <= 0 || height <= 0) return null
return toNdc(
normalize(normalizedX * container.width, offsetX, offsetX + width),
normalize(normalizedY * container.height, offsetY, offsetY + height)
)
}
export type Load3dActivityFlags = {
mouseOnNode: boolean
mouseOnScene: boolean

View File

@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { i18n, st, stRaw } from './i18n'
const TEST_NAMESPACE = 'safeTranslationTest'
beforeEach(() => {
i18n.global.locale.value = 'en'
const messages = i18n.global.getLocaleMessage('en')
delete (messages as Record<string, unknown>)[TEST_NAMESPACE]
i18n.global.setLocaleMessage('en', messages)
})
describe('st', () => {
it('returns the fallback when the key is not found', () => {
expect(st('safeTranslationTest.missing', 'Fallback value')).toBe(
'Fallback value'
)
})
it('uses compiled translations for valid locale messages', () => {
i18n.global.mergeLocaleMessage('en', {
safeTranslationTest: {
valid: 'Translated value'
}
})
expect(st('safeTranslationTest.valid', 'Fallback value')).toBe(
'Translated value'
)
})
it('returns raw locale messages when vue-i18n compilation fails', () => {
const message = 'Provided by @acme/model with JSON such as {"mode":"fast"}'
i18n.global.mergeLocaleMessage('en', {
safeTranslationTest: {
invalidLinkedFormat: message
}
})
expect(
st('safeTranslationTest.invalidLinkedFormat', 'Fallback value')
).toBe(message)
})
})
describe('stRaw', () => {
it('returns raw locale messages for valid keys', () => {
i18n.global.mergeLocaleMessage('en', {
safeTranslationTest: {
rawValue: 'Raw value'
}
})
expect(stRaw('safeTranslationTest.rawValue', 'Fallback value')).toBe(
'Raw value'
)
})
it('returns raw messages containing vue-i18n syntax', () => {
const message = 'Provided by @acme/model with JSON such as {"mode":"fast"}'
i18n.global.mergeLocaleMessage('en', {
safeTranslationTest: {
rawSyntax: message
}
})
expect(stRaw('safeTranslationTest.rawSyntax', 'Fallback value')).toBe(
message
)
})
it('returns the fallback when the key is not found', () => {
expect(stRaw('safeTranslationTest.rawMissing', 'Fallback value')).toBe(
'Fallback value'
)
})
})

View File

@@ -159,15 +159,28 @@ export const te: (typeof i18n.global)['te'] = i18n.global.te
export const d: (typeof i18n.global)['d'] = i18n.global.d
const tm = i18n.global.tm
function rawTranslationOrFallback(key: string, fallbackMessage: string) {
const message = tm(key)
return typeof message === 'string' ? message : fallbackMessage
}
/**
* Safe translation function that returns the fallback message if the key is not found.
* Invalid message syntax falls back to the raw locale message instead of crashing.
*
* @param key - The key to translate.
* @param fallbackMessage - The fallback message to use if the key is not found.
*/
export function st(key: string, fallbackMessage: string) {
// The normal defaultMsg overload fails in some cases for custom nodes
return te(key) ? t(key) : fallbackMessage
if (!te(key)) return fallbackMessage
try {
// The normal defaultMsg overload fails in some cases for custom nodes
return t(key)
} catch (error) {
if (!(error instanceof SyntaxError)) throw error
return rawTranslationOrFallback(key, fallbackMessage)
}
}
/**
@@ -180,6 +193,5 @@ export function st(key: string, fallbackMessage: string) {
export function stRaw(key: string, fallbackMessage: string) {
if (!te(key)) return fallbackMessage
const message = tm(key)
return typeof message === 'string' ? message : fallbackMessage
return rawTranslationOrFallback(key, fallbackMessage)
}

View File

@@ -18,6 +18,7 @@ import {
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import type { SerializedNodeId } from '@/types/nodeId'
import { toNodeId } from '@/types/nodeId'
import {
LGraph,
LGraphNode,
@@ -86,6 +87,23 @@ interface TestSubgraphNodeOptions {
size?: [number, number]
}
interface BoundaryLinkedSubgraphOptions {
rootGraph?: LGraph
hostId?: SerializedNodeId
interiorId?: SerializedNodeId
boundaryName?: string
inputName?: string
hostTitle?: string
interiorType?: string
}
export interface BoundaryLinkedSubgraphFixture {
rootGraph: LGraph
subgraph: Subgraph
host: SubgraphNode
interior: LGraphNode
}
interface NestedSubgraphOptions {
depth?: number
nodesPerLevel?: number
@@ -269,6 +287,32 @@ export function createTestSubgraphNode(
return new SubgraphNode(parentGraph, subgraph, instanceData)
}
export function createBoundaryLinkedSubgraph({
rootGraph = createTestRootGraph(),
hostId = 12,
interiorId = 5,
boundaryName = 'seed',
inputName = 'seed_input',
hostTitle = 'Host Subgraph',
interiorType = 'InteriorNode'
}: BoundaryLinkedSubgraphOptions = {}): BoundaryLinkedSubgraphFixture {
const subgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: boundaryName, type: '*' }]
})
const host = createTestSubgraphNode(subgraph, { id: hostId })
host.title = hostTitle
rootGraph.add(host)
const interior = new LGraphNode(interiorType)
interior.id = toNodeId(interiorId)
const input = interior.addInput(inputName, '*')
subgraph.add(interior)
subgraph.inputNode.slots[0].connect(input, interior)
return { rootGraph, subgraph, host, interior }
}
export function setupComplexPromotionFixture(): {
graph: LGraph
subgraph: Subgraph

View File

@@ -1091,6 +1091,7 @@
"runWorkflow": "Run workflow (Shift to queue at front)",
"runWorkflowFront": "Run workflow (Queue at front)",
"runWorkflowDisabled": "Workflow contains unsupported nodes (highlighted red). Remove these to run the workflow.",
"runWorkflowDisabledNodes": "Workflows with disabled nodes cannot be run. Check the Errors tab for details.",
"run": "Run",
"stopRunInstant": "Stop Run (Instant)",
"stopRunInstantTooltip": "Stop running",
@@ -1565,6 +1566,8 @@
"Execution": "Execution",
"PLY": "PLY",
"Workspace": "Workspace",
"Members": "Members",
"PartnerNodes": "Allowlist",
"Error System": "Error System",
"Other": "Other",
"Secrets": "Secrets",
@@ -2630,7 +2633,7 @@
"additionalCreditsInfo": "About additional credits",
"additionalCredits": "Additional credits",
"additionalCreditsInUse": "In use",
"usedAfterMonthly": "Used after monthly runs out",
"usedAfterMonthly": "Used after plan credits run out",
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
@@ -2868,7 +2871,10 @@
"planUpdated": "Your plan has been successfully updated.",
"receiptEmailed": "A receipt has been emailed to you.",
"sendInvites": "Send invites"
}
},
"enterprisePlanName": "Enterprise",
"percentUsed": "{percent}% used",
"usageProgress": "{used} of {total} credits used"
},
"userSettings": {
"title": "My Account Settings",
@@ -2883,7 +2889,7 @@
"workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
"inviteLimitReached": "Your workspace is at the member limit",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
@@ -2898,12 +2904,17 @@
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
"pendingCount": "Pending ({count})"
"pendingCount": "Pending ({count})",
"membersCount": "Members ({count})",
"pending": "Pending"
},
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"role": "Role"
"role": "Role",
"creditsUsed": "Credits used this month",
"email": "Email",
"lastActivity": "Last activity"
},
"actions": {
"resendInvite": "Resend invite",
@@ -2919,14 +2930,23 @@
"contactUs": "Contact us",
"noInvites": "No pending invites",
"noMembers": "No members",
"searchPlaceholder": "Search..."
"searchPlaceholder": "Search...",
"activity": {
"daysAgo": "{count} day ago | {count} days ago",
"hoursAgo": "{n} hr ago",
"justNow": "just now",
"minutesAgo": "{n} min ago",
"never": "—"
},
"membersUsage": "{count} of {max} total members."
},
"menu": {
"editWorkspace": "Edit workspace details",
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
"creatorCannotLeave": "The workspace creator can't leave the workspace they created",
"renameWorkspace": "Rename Workspace"
},
"editWorkspaceDialog": {
"title": "Edit workspace details",
@@ -2951,12 +2971,12 @@
"error": "Failed to remove member"
},
"changeRoleDialog": {
"promoteTitle": "Make {name} an owner?",
"promoteTitle": "Make {name} an admin?",
"promoteIntro": "They'll be able to:",
"promotePermissionCredits": "Add additional credits",
"promotePermissionManage": "Manage members, payment methods, and workspace settings",
"promotePermissionRoles": "Promote and demote other owners (except the workspace creator).",
"promoteConfirm": "Make owner",
"promotePermissionRoles": "Promote and demote other admins (except the workspace creator).",
"promoteConfirm": "Make admin",
"demoteTitle": "Demote {name} to member?",
"demoteMessage": "They'll lose admin access.",
"demoteConfirm": "Demote to member",
@@ -3015,6 +3035,105 @@
"failedToDeleteWorkspace": "Failed to delete workspace",
"failedToLeaveWorkspace": "Failed to leave workspace",
"failedToFetchWorkspaces": "Failed to load workspaces"
},
"charactersLeft": "{count} character left | {count} characters left",
"doubleClickToRename": "Double-click to rename",
"editWorkspaceImage": "Edit workspace image",
"memberLimitDialog": {
"message": "All seats are filled. To invite someone new, remove a member, rescind an invite, or request more seats.",
"title": "Workspace is at the member limit"
},
"requestMore": "Request more",
"workflowQueuedDialog": {
"message": "Max workflow capacity reached. It'll start automatically as capacity opens up. If this happens often, you can also request for more capacity.",
"title": "Your workflow is queued"
},
"billingStatus": {
"ending": {
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
"reactivate": "Reactivate plan",
"title": "Your team plan ends on {date}"
},
"outOfCredits": {
"addCredits": "Add credits",
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
"dismiss": "Dismiss",
"title": "Out of credits"
},
"paused": {
"body": "This workspace's subscription is paused. Update payment to resume.",
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method.",
"title": "Subscription paused"
},
"updatePayment": "Update payment",
"warning": {
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
"title": "Payment declined"
}
},
"overview": {
"changePlan": "Change plan",
"inactive": {
"reactivate": "Reactivate plan",
"subtitle": "Reactivate your team plan to add more members and run workflows",
"subtitleEnterprise": "Reactivate your enterprise plan to add more members and run workflows",
"title": "Inactive team subscription",
"titleEnterprise": "Inactive enterprise subscription"
},
"learnMore": "Learn more",
"managePayment": "Manage payment",
"messageSupport": "Message support",
"paused": "Paused",
"perMonth": "mo",
"pricingTable": "Partner Node pricing table",
"renewsOn": "Renews on {date}",
"seeMore": "See more",
"snapshot": {
"creditsUsed": "Credits used",
"empty": {
"recentActivity": "No activity yet",
"topSpenders": "No credits used yet this month"
},
"lastActivity": "Last activity",
"recentActivity": "Recent activity",
"topSpenders": "Top spenders",
"user": "User"
}
},
"allowlist": {
"disableAll": "Disable all",
"enableAll": "Enable all",
"tabs": {
"partnerNodes": "Partner nodes"
}
},
"partnerNodes": {
"autoEnableLabel": "Automatically enable newly added partner nodes",
"autoEnableSubject": "newly added partner nodes",
"autoEnableVerb": "auto-enable",
"bulkToggle": "Enable or disable selected partner nodes",
"clearSelection": "Clear selection",
"collapseProvider": "Collapse {partner} partner nodes",
"columns": {
"lastModified": "Last modified",
"name": "Partner Node",
"nodes": "Nodes"
},
"description": "Choose which partner nodes your team can use. Workflows with disabled nodes cannot be run.",
"empty": "No partner nodes match your search.",
"expandProvider": "Expand {partner} partner nodes",
"groupCount": "{enabled}/{total} enabled",
"groupToggle": "Enable or disable {partner} partner nodes",
"loadError": "Failed to load partner nodes",
"loading": "Loading partner nodes...",
"neverModified": "—",
"nodeToggle": "Enable or disable {name}",
"retry": "Retry",
"searchPlaceholder": "Search partner nodes",
"selectAll": "Select all partner nodes",
"selectedCount": "{count} node selected | {count} nodes selected",
"updateError": "Failed to update partner nodes"
}
},
"teamWorkspacesDialog": {
@@ -3027,7 +3146,7 @@
"newWorkspace": "New workspace",
"namePlaceholder": "e.g. Marketing Team",
"createWorkspace": "Create workspace",
"nameValidationError": "Name must be 150 characters using letters, numbers, spaces, or common punctuation."
"nameValidationError": "Name must be 130 characters using letters, numbers, spaces, or common punctuation."
},
"workspaceSwitcher": {
"switchWorkspace": "Switch workspace",
@@ -3037,7 +3156,8 @@
"roleMember": "Member",
"createWorkspace": "Create a workspace",
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
"failedToSwitch": "Failed to switch workspace"
"failedToSwitch": "Failed to switch workspace",
"roleAdmin": "Admin"
},
"selectionToolbox": {
"executeButton": {
@@ -3924,6 +4044,12 @@
"errorNodesSummary": "{nodes} nodes — {count} error | {nodes} nodes — {count} errors",
"errorsSummary": "{count} error | {count} errors",
"resolveBeforeRun": "Resolve before running the workflow",
"disabledNodes": {
"title": "Disabled node | Disabled nodes",
"message": "This node has been disabled by your team admin. Use a different node. | These nodes have been disabled by your team admin. Use different nodes.",
"toastDetail": "This node has been disabled by your team admin. | These nodes have been disabled by your team admin.",
"viewDetails": "View details"
},
"expand": "Expand",
"collapse": "Collapse",
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
@@ -4423,7 +4549,11 @@
"hideDevOnly": "Hide Dev-Only Nodes",
"hideDevOnlyDescription": "Hides nodes marked as dev-only unless dev mode is enabled",
"hideSubgraph": "Hide Subgraph Nodes",
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search"
"hideSubgraphDescription": "Temporarily hides subgraph nodes from node library and search",
"hideDisabledPartnerNodes": "Hide Admin-Disabled Partner Nodes"
},
"nodeSearch": {
"disabledByTeamAdmin": "This node has been disabled by your team admin. | These nodes have been disabled by your team admin."
},
"secrets": {
"title": "API Keys & Secrets",

View File

@@ -0,0 +1,35 @@
import { createI18n } from 'vue-i18n'
import { describe, expect, it } from 'vitest'
import { escapeVueI18nMessageSyntax } from '@comfyorg/shared-frontend-utils/formatUtil'
/**
* Node descriptions are compiled by vue-i18n via `t()`/`st()`, which parses
* `@ { } | %` as message syntax — a literal `@` even crashes the compiler with
* `Invalid linked format` (this broke the whole app after the 1.47.7 locale
* sync). `collect-i18n-node-defs.ts` escapes such values with
* `escapeVueI18nMessageSyntax` before writing them; this guards that the escaped
* output actually compiles and renders the original literal text.
*/
describe('escapeVueI18nMessageSyntax output is compiled safely by vue-i18n', () => {
const compile = (message: string) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { value: message } }
})
return i18n.global.t('value')
}
it.for([
'clips (tagged @Audio1-3 in the prompt)',
'support@comfy.org',
'resolution {width}x{height}',
'foreground | background',
'50%{done}',
'all of @ { } | % together',
'no special chars here'
])('renders %s as the original literal text', (raw) => {
expect(compile(escapeVueI18nMessageSyntax(raw))).toBe(raw)
})
})

View File

@@ -1,67 +1,48 @@
<template>
<div class="relative mx-2">
<div
data-testid="assets-selection-bar"
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
<SelectionBar
data-testid="assets-selection-bar"
:label="$t('mediaAsset.selection.selectedCount', { count })"
:deselect-label="$t('mediaAsset.selection.deselectAll')"
@deselect="emit('deselect')"
>
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.downloadSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-download-selected"
:aria-label="$t('mediaAsset.selection.downloadSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('download')"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<template v-if="showDelete">
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.deselectAll'),
value: $t('mediaAsset.selection.deleteSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-deselect-selected"
:aria-label="$t('mediaAsset.selection.deselectAll')"
data-testid="assets-delete-selected"
:aria-label="$t('mediaAsset.selection.deleteSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('deselect')"
@click="emit('delete')"
>
<i class="icon-[lucide--x] size-4" />
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
{{ $t('mediaAsset.selection.selectedCount', { count }) }}
</span>
<div class="ml-auto flex shrink-0 items-center gap-1">
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.downloadSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-download-selected"
:aria-label="$t('mediaAsset.selection.downloadSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('download')"
>
<i class="icon-[lucide--download] size-4" />
</Button>
<template v-if="showDelete">
<span class="h-6 w-px bg-base-background/20" aria-hidden="true" />
<Button
v-tooltip.top="{
value: $t('mediaAsset.selection.deleteSelected'),
showDelay: 300
}"
variant="inverted"
size="icon-lg"
type="button"
data-testid="assets-delete-selected"
:aria-label="$t('mediaAsset.selection.deleteSelected')"
class="rounded-lg hover:bg-base-background/10"
@click="emit('delete')"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
</template>
</div>
</div>
</div>
</template>
</SelectionBar>
</template>
<script setup lang="ts">
import SelectionBar from '@/components/common/SelectionBar.vue'
import Button from '@/components/ui/button/Button.vue'
const { count, showDelete = true } = defineProps<{

View File

@@ -53,6 +53,7 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
balance: computed(() => state.balance),
subscription: computed(() => state.subscription),
isPaused: computed(() => false),
isActiveSubscription: computed(() => state.isActiveSubscription),
isFreeTier: computed(() => state.isFreeTier),
currentTeamCreditStop: computed(() => state.currentTeamCreditStop),
@@ -97,24 +98,14 @@ const i18n = createI18n({
remaining: 'remaining',
refreshCredits: 'Refresh credits',
monthly: 'Monthly',
refillsDate: 'Refills {date}',
refillsNextCycle: 'Refills next cycle',
creditsUsed: '{used} used',
creditsLeftOfTotal: '{remaining} left of {total}',
monthlyUsageProgress: '{used} of {total} monthly credits used',
yearly: 'Yearly',
percentUsed: '{percent}% used',
usageProgress: '{used} of {total} credits used',
additionalCreditsInfo: 'About additional credits',
additionalCreditsTooltip: 'Credits you add on top of your plan.',
additionalCredits: 'Additional credits',
additionalCreditsInUse: 'In use',
usedAfterMonthly: 'Used after monthly runs out',
monthlyCreditsUsedUpTitle:
'Monthly credits are used up. Refills {date}',
monthlyCreditsUsedUpTitleNoDate: 'Monthly credits are used up',
monthlyCreditsUsedUpDescription:
"You're now spending additional credits.",
outOfCreditsTitle: "You're out of credits. Credits refill {date}",
outOfCreditsTitleNoDate: "You're out of credits",
outOfCreditsDescription: 'Add more credits to continue generating.',
usedAfterMonthly: 'Used after plan credits run out',
addCredits: 'Add credits',
upgradeToAddCredits: 'Upgrade to add credits'
}
@@ -178,27 +169,19 @@ describe('CreditsTile', () => {
it('renders the monthly usage bar and additional breakdown', () => {
activeProSubscription()
const { container } = renderTile()
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678.
// PRO monthly allowance = 21,100; remaining 422 -> used 20,678 -> 98%.
expect(container.textContent).toContain('Monthly')
expect(container.textContent).toMatch(/Refills Feb/)
expect(container.textContent).toContain('20,678 used')
expect(container.textContent).toContain('422 left of 21,100')
expect(container.textContent).toContain('98% used')
expect(container.textContent).toContain('Additional credits')
expect(container.textContent).toContain('633')
expect(container.textContent).toContain('Used after monthly runs out')
expect(container.textContent).toContain('Used after plan credits run out')
})
it('renders a compact monthly summary for narrow containers', () => {
activeProSubscription()
const { container } = renderTile()
expect(container.textContent).toContain('422 left of 21K')
})
it('uses the team credit stop monthly grant for the monthly total', () => {
it('uses the team credit stop grant for a monthly allowance', () => {
state.isActiveSubscription = true
state.subscription = {
tier: 'TEAM',
duration: 'ANNUAL',
duration: 'MONTHLY',
renewalDate: '2026-02-20T12:00:00Z'
}
state.currentTeamCreditStop = {
@@ -207,13 +190,15 @@ describe('CreditsTile', () => {
stop_usd: 2500
}
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
const { container } = renderTile()
// Monthly total is the stop's raw monthly grant, not the tier fallback,
// and is not multiplied by 12 for annual billing.
expect(container.textContent).toContain('422 left of 527,500')
renderTile()
// Allowance is the stop's grant, not the tier fallback.
expect(screen.getByRole('progressbar')).toHaveAttribute(
'aria-valuemax',
'527500'
)
})
it('uses the per-month nominal grant for an annual personal tier', () => {
it('grants the full year upfront for an annual plan', () => {
state.isActiveSubscription = true
state.subscription = {
tier: 'PRO',
@@ -221,35 +206,25 @@ describe('CreditsTile', () => {
renewalDate: '2026-02-20T12:00:00Z'
}
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
const { container } = renderTile()
// Annual billing still grants the monthly nominal (21,100), not 12x.
expect(container.textContent).toContain('422 left of 21,100')
expect(container.textContent).not.toContain('253,200')
renderTile()
// Annual plans grant the whole year at once: 21,100 x 12.
expect(screen.getByRole('progressbar')).toHaveAttribute(
'aria-valuemax',
'253200'
)
})
it('falls back to a dateless refills label when renewal date is missing', () => {
activeProSubscription()
state.subscription = { tier: 'PRO', duration: 'MONTHLY', renewalDate: null }
const { container } = renderTile()
expect(container.textContent).toContain('Refills next cycle')
expect(container.textContent).not.toContain('Refills Feb')
})
it('uses a dateless out-of-credits notice when renewal date is invalid', () => {
activeProSubscription()
it('labels the allowance by billing duration (yearly for annual)', () => {
state.isActiveSubscription = true
state.subscription = {
tier: 'PRO',
duration: 'MONTHLY',
renewalDate: 'not-a-date'
duration: 'ANNUAL',
renewalDate: '2026-02-20T12:00:00Z'
}
state.balance = {
amountMicros: 0,
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 0
}
const { container } = renderTile()
expect(container.textContent).toContain("You're out of credits")
expect(container.textContent).not.toContain('Credits refill')
state.balance = { amountMicros: 0, cloudCreditBalanceMicros: 200 }
renderTile()
expect(screen.getByText('Yearly')).toBeInTheDocument()
expect(screen.queryByText('Monthly')).not.toBeInTheDocument()
})
it('hides the breakdown and forces zeros in the zero state', () => {
@@ -271,11 +246,9 @@ describe('CreditsTile', () => {
expect(screen.queryByText('Add credits')).toBeNull()
})
it('shows no depletion notice or in-use badge while monthly credits remain', () => {
it('shows no in-use badge while monthly credits remain', () => {
activeProSubscription()
const { container } = renderTile()
expect(container.textContent).not.toContain('Monthly credits are used up')
expect(container.textContent).not.toContain("You're out of credits")
renderTile()
expect(screen.queryByText('In use')).toBeNull()
})
@@ -286,42 +259,29 @@ describe('CreditsTile', () => {
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 300
}
const { container } = renderTile()
expect(container.textContent).toContain(
'Monthly credits are used up. Refills Feb 20'
)
expect(container.textContent).toContain(
"You're now spending additional credits."
)
renderTile()
expect(screen.getByText('In use')).toBeTruthy()
expect(screen.getByText('Add credits').dataset.variant).toBe('secondary')
expect(screen.getByText('Add credits').dataset.variant).toBe('tertiary')
})
it('emphasizes add-credits when fully out of credits', () => {
it('emphasizes add-credits when fully out of credits, without a punch-out notice', () => {
activeProSubscription()
state.balance = {
amountMicros: 0,
cloudCreditBalanceMicros: 0,
prepaidBalanceMicros: 0
}
const { container } = renderTile()
expect(container.textContent).toContain(
"You're out of credits. Credits refill Feb 20"
)
expect(container.textContent).toContain(
'Add more credits to continue generating.'
)
renderTile()
expect(screen.queryByText('In use')).toBeNull()
expect(screen.getByText('Add credits').dataset.variant).toBe('inverted')
})
it('suppresses the depletion notice until the balance has loaded', () => {
it('shows no in-use badge until the balance has loaded', () => {
activeProSubscription()
state.balance = null
state.isLoading = true
const { container } = renderTile()
expect(container.textContent).not.toContain('Monthly credits are used up')
expect(container.textContent).not.toContain("You're out of credits")
renderTile()
expect(screen.queryByText('In use')).toBeNull()
})
it('routes add-credits through telemetry + the top-up dialog', async () => {

View File

@@ -1,6 +1,15 @@
<template>
<div
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
:class="
cn(
'@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5 transition-opacity',
// Paused subscriptions can't spend credits, so dim the whole tile to
// read as frozen and defer to the Update-payment banner. A lapsed plan
// (frozen) reads the same way.
(isPaused || frozen) && 'opacity-50',
customClass
)
"
>
<Button
variant="muted-textonly"
@@ -19,8 +28,10 @@
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="flex items-baseline gap-2">
<i class="icon-[lucide--component] size-4 self-center text-credit" />
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
<i class="icon-[lucide--coins] size-4 self-center text-credit" />
<span class="text-2xl leading-none font-bold tabular-nums">{{
displayTotal
}}</span>
<span class="text-sm text-muted @max-[300px]:hidden">{{
$t('subscription.remaining')
}}</span>
@@ -28,37 +39,23 @@
</div>
<template v-if="showBreakdown">
<div
v-if="emptyStateNotice"
class="flex items-start gap-2 rounded-lg bg-base-background p-3 text-sm"
>
<i
class="mt-0.5 icon-[lucide--info] size-4 shrink-0 text-base-foreground"
/>
<div class="flex flex-col gap-1">
<span class="text-base-foreground">{{ emptyStateNotice.title }}</span>
<span class="text-muted">{{ emptyStateNotice.description }}</span>
</div>
</div>
<div
v-if="showBar"
:class="cn('flex flex-col gap-2', isMonthlyDepleted && 'opacity-30')"
:class="cn('flex flex-col gap-2', isAllowanceDepleted && 'opacity-30')"
>
<div class="flex items-center justify-between text-sm">
<span class="text-text-primary">{{
$t('subscription.monthly')
}}</span>
<span class="text-muted">{{ cycleLabel }}</span>
<span class="text-muted">
{{ refillsLabel }}
{{ cycleStatusLabel }}
</span>
</div>
<div
role="progressbar"
:aria-label="cycleLabel"
:aria-valuenow="usage.used"
:aria-valuemin="0"
:aria-valuemax="monthlyTotalCredits ?? 0"
:aria-valuetext="monthlyUsageLabel"
:aria-valuemax="allowanceTotalCredits ?? 0"
:aria-valuetext="cycleUsageLabel"
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
>
<div
@@ -66,40 +63,6 @@
:style="{ width: usedBarWidth }"
/>
</div>
<div class="flex items-center justify-between gap-2 text-sm">
<Skeleton
v-if="isLoadingBalance"
class="@max-[300px]:hidden"
width="5rem"
height="1rem"
/>
<span v-else class="text-muted @max-[300px]:hidden">
{{ $t('subscription.creditsUsed', { used: usedDisplay }) }}
</span>
<Skeleton v-if="isLoadingBalance" width="9rem" height="1rem" />
<span
v-else
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--component] size-4 text-credit" />
<span class="@max-[180px]:hidden">
{{
$t('subscription.creditsLeftOfTotal', {
remaining: monthlyBonusCredits,
total: monthlyTotalDisplay
})
}}
</span>
<span class="hidden @max-[180px]:inline">
{{
$t('subscription.creditsLeftOfTotal', {
remaining: monthlyRemainingCompact,
total: monthlyTotalCompact
})
}}
</span>
</span>
</div>
</div>
<div class="h-px w-full bg-interface-stroke" />
@@ -118,7 +81,7 @@
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('subscription.additionalCreditsInfo')"
class="text-muted"
class="flex cursor-help appearance-none items-center border-none bg-transparent p-0 text-muted transition-colors hover:text-text-primary"
>
<i class="icon-[lucide--info] size-4" />
</Button>
@@ -132,9 +95,9 @@
<Skeleton v-if="isLoadingBalance" width="3rem" height="1rem" />
<span
v-else
class="flex items-center gap-1 font-bold text-text-primary"
class="flex items-center gap-1 font-bold text-text-primary tabular-nums"
>
<i class="icon-[lucide--component] size-4 text-credit" />
<i class="icon-[lucide--coins] size-4 text-credit" />
{{ displayPrepaid }}
</span>
</div>
@@ -156,15 +119,10 @@
</Button>
<Button
v-else
:variant="isOutOfCredits ? 'inverted' : 'secondary'"
:variant="isOutOfCredits ? 'inverted' : 'tertiary'"
size="lg"
:class="
cn(
'w-full font-normal',
!isOutOfCredits &&
'bg-interface-menu-component-surface-selected text-text-primary'
)
"
class="w-full font-normal"
:disabled="isPaused || frozen"
@click="handleAddCredits"
>
{{ $t('subscription.addCredits') }}
@@ -178,6 +136,7 @@ import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import type { HTMLAttributes } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCredits } from '@/base/credits/comfyCredits'
@@ -186,40 +145,45 @@ import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSubscriptionCredits } from '@/platform/cloud/subscription/composables/useSubscriptionCredits'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits
} from '@/platform/cloud/subscription/constants/tierPricing'
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
import { useTelemetry } from '@/platform/telemetry'
import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useDialogService } from '@/services/dialogService'
const { zeroState = false } = defineProps<{
const {
zeroState = false,
frozen = false,
class: customClass
} = defineProps<{
/** Forces the zero-credit display (e.g. unsubscribed / member view). */
zeroState?: boolean
/**
* Renders the full breakdown but dimmed and non-interactive, for a lapsed
* subscription that still has a shape to show. Mirrors the paused treatment.
*/
frozen?: boolean
class?: HTMLAttributes['class']
}>()
const { locale, t } = useI18n()
const {
subscription,
isPaused,
balance,
isActiveSubscription,
isFreeTier,
currentTeamCreditStop,
fetchBalance,
fetchStatus
} = useBillingContext()
const {
monthlyBonusCredits,
prepaidCredits,
totalCredits,
monthlyBonusCreditsValue,
prepaidCreditsValue,
isLoadingBalance
isLoadingBalance,
allowanceTotalCredits,
usage
} = useSubscriptionCredits()
const { permissions } = useWorkspaceUI()
const { showPricingTable } = useSubscriptionDialog()
@@ -227,40 +191,18 @@ const { wrapWithErrorHandlingAsync } = useErrorHandling()
const dialogService = useDialogService()
const telemetry = useTelemetry()
const tierKey = computed(() => {
const tier = subscription.value?.tier
if (!tier) return DEFAULT_TIER_KEY
return TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY
})
const monthlyTotalCredits = computed<number | null>(() => {
const teamStop = currentTeamCreditStop.value
if (teamStop) return teamStop.credits_monthly
return getTierCredits(tierKey.value)
})
const usage = computed(() =>
computeMonthlyUsage(
monthlyBonusCreditsValue.value,
monthlyTotalCredits.value ?? 0
)
const cycleLabel = computed(() =>
subscription.value?.duration === 'ANNUAL'
? t('subscription.yearly')
: t('subscription.monthly')
)
const refillsDateShort = computed(() => {
const raw = subscription.value?.renewalDate
if (!raw) return ''
const date = new Date(raw)
return Number.isNaN(date.getTime())
? ''
: date.toLocaleDateString(locale.value, { month: 'short', day: 'numeric' })
})
const cycleUsedPercent = computed(() =>
Math.round(usage.value.usedFraction * 100)
)
const hasRefillsDate = computed(() => refillsDateShort.value !== '')
const refillsLabel = computed(() =>
hasRefillsDate.value
? t('subscription.refillsDate', { date: refillsDateShort.value })
: t('subscription.refillsNextCycle')
const cycleStatusLabel = computed(() =>
t('subscription.percentUsed', { percent: cycleUsedPercent.value })
)
const formatCreditCount = (value: number) =>
@@ -270,82 +212,58 @@ const formatCreditCount = (value: number) =>
numberOptions: { maximumFractionDigits: 0 }
})
const monthlyTotalDisplay = computed(() => {
const total = monthlyTotalCredits.value
const allowanceTotalDisplay = computed(() => {
const total = allowanceTotalCredits.value
return total === null ? '—' : formatCreditCount(total)
})
const usedDisplay = computed(() => formatCreditCount(usage.value.used))
const compactNumber = computed(
() => new Intl.NumberFormat(locale.value, { notation: 'compact' })
)
const monthlyRemainingCompact = computed(() =>
compactNumber.value.format(monthlyBonusCreditsValue.value)
)
const monthlyTotalCompact = computed(() => {
const total = monthlyTotalCredits.value
return total === null ? '—' : compactNumber.value.format(total)
})
const displayTotal = computed(() => (zeroState ? '0' : totalCredits.value))
const displayPrepaid = computed(() => (zeroState ? '0' : prepaidCredits.value))
const usedBarWidth = computed(
() => `${(usage.value.usedFraction * 100).toFixed(2)}%`
)
const monthlyUsageLabel = computed(() =>
t('subscription.monthlyUsageProgress', {
const cycleUsageLabel = computed(() =>
t('subscription.usageProgress', {
used: usedDisplay.value,
total: monthlyTotalDisplay.value
total: allowanceTotalDisplay.value
})
)
const showBreakdown = computed(() => isActiveSubscription.value && !zeroState)
const showBreakdown = computed(
() => (isActiveSubscription.value || frozen) && !zeroState
)
const showBar = computed(
() =>
showBreakdown.value &&
monthlyTotalCredits.value !== null &&
monthlyTotalCredits.value > 0
allowanceTotalCredits.value !== null &&
allowanceTotalCredits.value > 0
)
const showActionButton = computed(
() => isActiveSubscription.value && !zeroState && permissions.value.canTopUp
() =>
(isActiveSubscription.value || frozen) &&
!zeroState &&
permissions.value.canTopUp
)
const isMonthlyDepleted = computed(
const isAllowanceDepleted = computed(
() =>
!isPaused.value &&
!frozen &&
showBar.value &&
!isLoadingBalance.value &&
balance.value != null &&
monthlyBonusCreditsValue.value <= 0
)
const isOutOfCredits = computed(
() => isMonthlyDepleted.value && prepaidCreditsValue.value <= 0
)
const isSpendingAdditional = computed(
() => isMonthlyDepleted.value && prepaidCreditsValue.value > 0
() => isAllowanceDepleted.value && prepaidCreditsValue.value > 0
)
// Fully out (monthly depleted and no additional credits left): emphasize the
// add-credits button. Spending-additional keeps the quieter tertiary.
const isOutOfCredits = computed(
() => isAllowanceDepleted.value && prepaidCreditsValue.value <= 0
)
const emptyStateNotice = computed(() => {
if (isOutOfCredits.value) {
return {
title: hasRefillsDate.value
? t('subscription.outOfCreditsTitle', { date: refillsDateShort.value })
: t('subscription.outOfCreditsTitleNoDate'),
description: t('subscription.outOfCreditsDescription')
}
}
if (isMonthlyDepleted.value) {
return {
title: hasRefillsDate.value
? t('subscription.monthlyCreditsUsedUpTitle', {
date: refillsDateShort.value
})
: t('subscription.monthlyCreditsUsedUpTitleNoDate'),
description: t('subscription.monthlyCreditsUsedUpDescription')
}
}
return null
})
const handleRefresh = wrapWithErrorHandlingAsync(async () => {
await Promise.all([fetchBalance(), fetchStatus()])

View File

@@ -6,6 +6,12 @@ import {
formatCreditsFromCents
} from '@/base/credits/comfyCredits'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import {
DEFAULT_TIER_KEY,
TIER_TO_KEY,
getTierCredits
} from '@/platform/cloud/subscription/constants/tierPricing'
import { computeMonthlyUsage } from '@/platform/cloud/subscription/utils/creditsProgress'
/**
* Composable for handling subscription credit calculations and formatting.
@@ -64,12 +70,44 @@ export function useSubscriptionCredits() {
creditsFromMicros(toValue(billingContext.balance)?.prepaidBalanceMicros)
)
// Total credits granted for the current billing cycle. Team plans read the
// credit stop; personal tiers read the tier grant. Annual plans front-load the
// whole year, so multiply the monthly nominal by the cycle length.
const cycleMonths = computed(() =>
toValue(billingContext.subscription)?.duration === 'ANNUAL' ? 12 : 1
)
const allowanceTotalCredits = computed<number | null>(() => {
const teamStop = toValue(billingContext.currentTeamCreditStop)
const tier = toValue(billingContext.subscription)?.tier
const tierKey = tier
? (TIER_TO_KEY[tier] ?? DEFAULT_TIER_KEY)
: DEFAULT_TIER_KEY
const monthly = teamStop
? teamStop.credits_monthly
: getTierCredits(tierKey)
return monthly === null ? null : monthly * cycleMonths.value
})
// Usage of that allowance drives the credits bar. Paused plans read as unused
// (credits are frozen), so force it to zero.
const usage = computed(() => {
const base = computeMonthlyUsage(
monthlyBonusCreditsValue.value,
allowanceTotalCredits.value ?? 0
)
return toValue(billingContext.isPaused)
? { ...base, used: 0, usedFraction: 0 }
: base
})
return {
totalCredits,
monthlyBonusCredits,
prepaidCredits,
monthlyBonusCreditsValue,
prepaidCreditsValue,
isLoadingBalance
isLoadingBalance,
allowanceTotalCredits,
usage
}
}

View File

@@ -1,13 +1,10 @@
import type {
ExecutionErrorWsMessage,
NodeError,
PromptError
} from '@/schemas/apiSchema'
import type { ExecutionErrorWsMessage, PromptError } from '@/schemas/apiSchema'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
import type { NodeValidationError } from '@/utils/executionErrorUtil'
export type NodeValidationError = NodeError['errors'][number]
export type { NodeValidationError }
export interface ResolvedErrorMessage {
catalogId?: string

View File

@@ -11,6 +11,12 @@ import {
translateOptionalCatalogMessage
} from './catalogI18n'
import type { CatalogParams, ErrorResolveContext } from './catalogI18n'
import {
INPUT_LEVEL_VALIDATION_ERROR_TYPES,
NODE_LEVEL_VALIDATION_ERROR_TYPES,
getInputConfigBounds,
isImageNotLoadedValidationError
} from '@/utils/executionErrorUtil'
const REQUIRED_INPUT_MISSING_TYPE = 'required_input_missing'
@@ -62,51 +68,31 @@ const VALUE_SPECIFIC_COPY_RULES: Record<
}
}
const NODE_LEVEL_VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> =
Object.fromEntries(
Array.from(NODE_LEVEL_VALIDATION_ERROR_TYPES, (type) => [
type,
{ catalogId: type, itemLabel: 'node' } satisfies ValidationCatalogRule
])
)
const INPUT_LEVEL_VALIDATION_ERROR_RULES: Record<
string,
ValidationCatalogRule
> = Object.fromEntries(
Array.from(INPUT_LEVEL_VALIDATION_ERROR_TYPES, (type) => [
type,
{ catalogId: type, itemLabel: 'nodeInput' } satisfies ValidationCatalogRule
])
)
const VALIDATION_ERROR_RULES: Record<string, ValidationCatalogRule> = {
...INPUT_LEVEL_VALIDATION_ERROR_RULES,
[REQUIRED_INPUT_MISSING_TYPE]: {
catalogId: MISSING_CONNECTION_CATALOG_ID,
itemLabel: 'nodeInput'
},
bad_linked_input: {
catalogId: 'bad_linked_input',
itemLabel: 'nodeInput'
},
return_type_mismatch: {
catalogId: 'return_type_mismatch',
itemLabel: 'nodeInput'
},
invalid_input_type: {
catalogId: 'invalid_input_type',
itemLabel: 'nodeInput'
},
value_smaller_than_min: {
catalogId: 'value_smaller_than_min',
itemLabel: 'nodeInput'
},
value_bigger_than_max: {
catalogId: 'value_bigger_than_max',
itemLabel: 'nodeInput'
},
value_not_in_list: {
catalogId: 'value_not_in_list',
itemLabel: 'nodeInput'
},
custom_validation_failed: {
catalogId: 'custom_validation_failed',
itemLabel: 'nodeInput'
},
exception_during_inner_validation: {
catalogId: 'exception_during_inner_validation',
itemLabel: 'nodeInput'
},
exception_during_validation: {
catalogId: 'exception_during_validation',
itemLabel: 'node'
},
dependency_cycle: {
catalogId: 'dependency_cycle',
itemLabel: 'node'
}
...NODE_LEVEL_VALIDATION_ERROR_RULES
}
// Image-not-loaded shares the custom_validation_failed type, so type-keyed
@@ -131,26 +117,6 @@ function getInputName(error: NodeValidationError): string {
)
}
function getErrorText(error: NodeValidationError) {
return [
'message' in error ? error.message : undefined,
'details' in error ? error.details : undefined
]
.filter(Boolean)
.join('\n')
}
function isImageNotLoadedText(text: string): boolean {
return /invalid image file|\[errno 21\].*is a directory/i.test(text)
}
function isImageNotLoadedValidationError(error: NodeValidationError): boolean {
return (
error.type === 'custom_validation_failed' &&
isImageNotLoadedText(getErrorText(error))
)
}
function nodeInputItemLabel(nodeName: string, inputName: string): string {
return `${nodeName} - ${inputName}`
}
@@ -179,13 +145,7 @@ function getInputConfigValue(
error: NodeValidationError,
key: 'min' | 'max'
): string | undefined {
const inputConfig = error.extra_info?.input_config
if (!Array.isArray(inputConfig)) return undefined
const config = inputConfig[1]
if (!config || typeof config !== 'object') return undefined
return formatCatalogValue((config as Record<string, unknown>)[key])
return formatCatalogValue(getInputConfigBounds(error)[key])
}
function getInputConfigType(error: NodeValidationError): string | undefined {

View File

@@ -107,6 +107,7 @@ export type RemoteConfig = {
manager_survey_url?: string
linear_toggle_enabled?: boolean
team_workspaces_enabled?: boolean
partner_node_governance_enabled?: boolean
user_secrets_enabled?: boolean
node_library_essentials_enabled?: boolean
free_tier_credits?: number

View File

@@ -1,5 +1,11 @@
<template>
<BaseModalLayout content-title="" data-testid="settings-dialog" size="full">
<BaseModalLayout
content-title=""
data-testid="settings-dialog"
size="full"
header-height-class="h-22"
:content-padding="isWorkspacePanel ? 'flush' : 'default'"
>
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--settings]" />
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
@@ -48,6 +54,7 @@
id="keybinding-panel-header"
class="flex-1"
/>
<WorkspaceSettingsHeader v-else-if="isWorkspacePanel" />
</template>
<template #header-right-area>
@@ -55,6 +62,7 @@
v-if="activeCategoryKey === 'keybinding'"
id="keybinding-panel-actions"
/>
<WorkspaceMenuButton v-else-if="isWorkspacePanel" />
</template>
<template #content>
@@ -93,8 +101,11 @@ import NavTitle from '@/components/widget/nav/NavTitle.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import ColorPaletteMessage from '@/platform/settings/components/ColorPaletteMessage.vue'
import SettingsPanel from '@/platform/settings/components/SettingsPanel.vue'
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
import WorkspaceSettingsHeader from '@/platform/workspace/components/dialogs/settings/WorkspaceSettingsHeader.vue'
import { useSettingSearch } from '@/platform/settings/composables/useSettingSearch'
import { useSettingUI } from '@/platform/settings/composables/useSettingUI'
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type {
@@ -135,6 +146,14 @@ const { fetchBalance } = useBillingContext()
const navRef = ref<HTMLElement | null>(null)
const activeCategoryKey = ref<string | null>(defaultCategory.value?.key ?? null)
// Let panels deep-link into a sibling panel (e.g. Overview → Members).
const { requestedPanelKey } = useSettingsNavigation()
watch(requestedPanelKey, (key) => {
if (!key) return
activeCategoryKey.value = key
requestedPanelKey.value = null
})
const searchableNavItems = computed(() =>
navGroups.value.flatMap((g) =>
g.items.map((item) => ({
@@ -172,6 +191,17 @@ const activePanel = computed(() => {
return findPanelByKey(activeCategoryKey.value)
})
const WORKSPACE_PANEL_KEYS: SettingPanelType[] = [
'workspace',
'workspace-members',
'workspace-partner-nodes'
]
const isWorkspacePanel = computed(
() =>
!!activeCategoryKey.value &&
WORKSPACE_PANEL_KEYS.some((key) => key === activeCategoryKey.value)
)
const getGroupSortOrder = (group: SettingTreeNode): number =>
Math.max(0, ...flattenTree<SettingParams>(group).map((s) => s.sortOrder ?? 0))

View File

@@ -19,7 +19,8 @@ const env = vi.hoisted(() => {
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy' as 'legacy' | 'workspace'
billingType: 'legacy' as 'legacy' | 'workspace',
canManagePartnerNodes: false
}
const fakeRef = <K extends keyof typeof state>(key: K) => ({
get value() {
@@ -75,6 +76,16 @@ vi.mock('@/platform/settings/settingStore', () => ({
getSettingInfo: vi.fn()
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: {
get value() {
return { canManagePartnerNodes: env.state.canManagePartnerNodes }
}
}
})
}))
interface MockSettingParams {
id: string
name: string
@@ -116,7 +127,8 @@ describe('useSettingUI', () => {
teamWorkspacesEnabled: false,
userSecretsEnabled: false,
isActiveSubscription: false,
billingType: 'legacy'
billingType: 'legacy',
canManagePartnerNodes: false
})
vi.mocked(useSettingStore).mockReturnValue({
@@ -233,5 +245,17 @@ describe('useSettingUI', () => {
}
}
})
it('shows the partner nodes entry only to owners and admins', () => {
env.state.canManagePartnerNodes = false
expect(navKeys(useSettingUI().navGroups.value)).not.toContain(
'workspace-partner-nodes'
)
env.state.canManagePartnerNodes = true
expect(navKeys(useSettingUI().navGroups.value)).toContain(
'workspace-partner-nodes'
)
})
})
})

View File

@@ -13,6 +13,7 @@ import {
} from '@/platform/settings/settingStore'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type { SettingPanelType, SettingParams } from '@/platform/settings/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type { NavGroupData } from '@/types/navTypes'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
@@ -28,6 +29,7 @@ const CATEGORY_ICONS: Record<string, string> = {
LiteGraph: 'icon-[lucide--workflow]',
'Mask Editor': 'icon-[lucide--pen-tool]',
Other: 'icon-[lucide--ellipsis]',
PartnerNodes: 'icon-[lucide--shield-check]',
PlanCredits: 'icon-[lucide--credit-card]',
secrets: 'icon-[lucide--key-round]',
'server-config': 'icon-[lucide--server]',
@@ -54,6 +56,7 @@ export function useSettingUI(
const { flags } = useFeatureFlags()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription, type: billingType } = useBillingContext()
const { permissions } = useWorkspaceUI()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
@@ -188,10 +191,40 @@ export function useSettingUI(
)
}
const membersPanel: SettingPanelItem = {
node: {
key: 'workspace-members',
label: 'Members',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/WorkspaceMembersPanelContent.vue')
)
}
const partnerNodesPanel: SettingPanelItem = {
node: {
key: 'workspace-partner-nodes',
label: 'PartnerNodes',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/AllowlistPanelContent.vue')
)
}
const shouldShowWorkspacePanel = computed(
() => teamWorkspacesEnabled.value && isLoggedIn.value
)
// Partner-node governance is Owner/Admin-only; Members never see the tab.
const shouldShowPartnerNodesPanel = computed(
() =>
shouldShowWorkspacePanel.value && permissions.value.canManagePartnerNodes
)
const secretsPanel: SettingPanelItem = {
node: {
key: 'secrets',
@@ -245,7 +278,8 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
...(shouldShowWorkspacePanel.value ? [workspacePanel, membersPanel] : []),
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel] : []),
keybindingPanel,
extensionPanel,
...(isDesktop ? [serverConfigPanel] : []),
@@ -295,7 +329,10 @@ export function useSettingUI(
key: 'workspace',
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
...(shouldShowWorkspacePanel.value
? [workspacePanel.node, membersPanel.node]
: []),
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel.node] : []),
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]

View File

@@ -0,0 +1,15 @@
import { ref } from 'vue'
import type { SettingPanelType } from '@/platform/settings/types'
// A one-shot request to switch the open Settings dialog to another panel, so a
// panel's content can deep-link into a sibling panel (e.g. Overview → Members).
const requestedPanelKey = ref<SettingPanelType | null>(null)
export function useSettingsNavigation() {
function navigateToPanel(key: SettingPanelType) {
requestedPanelKey.value = key
}
return { requestedPanelKey, navigateToPanel }
}

View File

@@ -87,3 +87,5 @@ export type SettingPanelType =
| 'subscription'
| 'user'
| 'workspace'
| 'workspace-members'
| 'workspace-partner-nodes'

View File

@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockApiClient, mockGetAuthHeaderOrThrow } = vi.hoisted(() => ({
mockApiClient: {
get: vi.fn(),
patch: vi.fn()
},
mockGetAuthHeaderOrThrow: vi.fn()
}))
vi.mock('axios', () => ({
default: {
create: vi.fn(() => mockApiClient)
}
}))
vi.mock('@/platform/auth/unified/remintRetry', () => ({
attachUnifiedRemintInterceptor: vi.fn()
}))
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((path: string) => `/api${path}`)
}
}))
vi.mock('@/stores/authStore', () => ({
useAuthStore: () => ({
getAuthHeaderOrThrow: mockGetAuthHeaderOrThrow
})
}))
import { partnerNodesApi } from './partnerNodesApi'
const AUTH_HEADER = { Authorization: 'Bearer test-token' }
describe('partnerNodesApi', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetAuthHeaderOrThrow.mockResolvedValue(AUTH_HEADER)
})
it('lists partner-node governance from the workspace resource', async () => {
const response = { partner_nodes: [], auto_enable_new: false }
mockApiClient.get.mockResolvedValue({ data: response })
await expect(partnerNodesApi.list()).resolves.toEqual(response)
expect(mockApiClient.get).toHaveBeenCalledWith(
'/api/workspace/partner-nodes',
{ headers: AUTH_HEADER }
)
})
it('updates one node through the bulk mutation contract', async () => {
mockApiClient.patch.mockResolvedValue({})
await partnerNodesApi.setEnabled('PartnerNode', false)
expect(mockApiClient.patch).toHaveBeenCalledWith(
'/api/workspace/partner-nodes',
{ node_ids: ['PartnerNode'], enabled: false },
{ headers: AUTH_HEADER }
)
})
it('updates a filtered set through the same resource', async () => {
mockApiClient.patch.mockResolvedValue({})
await partnerNodesApi.setEnabledBulk(['NodeA', 'NodeB'], true)
expect(mockApiClient.patch).toHaveBeenCalledWith(
'/api/workspace/partner-nodes',
{ node_ids: ['NodeA', 'NodeB'], enabled: true },
{ headers: AUTH_HEADER }
)
})
it('updates the default for newly cataloged nodes', async () => {
mockApiClient.patch.mockResolvedValue({})
await partnerNodesApi.setAutoEnableNew(true)
expect(mockApiClient.patch).toHaveBeenCalledWith(
'/api/workspace/partner-nodes',
{ auto_enable_new: true },
{ headers: AUTH_HEADER }
)
})
})

View File

@@ -0,0 +1,86 @@
import axios from 'axios'
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
/** A partner (paid-API) node the workspace can allow or block. */
export interface PartnerNode {
/** Canonical Comfy node type ID; matches the /object_info object key. */
id: string
name: string
partner: string
/** ISO date of the last governance change, or null if never modified. */
last_modified: string | null
enabled: boolean
}
export interface PartnerNodesResponse {
partner_nodes: PartnerNode[]
/** Workspace default applied to newly added partner nodes. */
auto_enable_new: boolean
}
interface BulkSetEnabledPayload {
node_ids: string[]
enabled: boolean
}
interface SetAutoEnablePayload {
auto_enable_new: boolean
}
const partnerNodesApiClient = axios.create({
timeout: 10000,
headers: { 'Content-Type': 'application/json' }
})
attachUnifiedRemintInterceptor(partnerNodesApiClient)
async function authHeader() {
return useAuthStore().getAuthHeaderOrThrow()
}
export const partnerNodesApi = {
/** Readable by every active workspace member. */
async list(): Promise<PartnerNodesResponse> {
const headers = await authHeader()
const response = await partnerNodesApiClient.get<PartnerNodesResponse>(
api.apiURL('/workspace/partner-nodes'),
{ headers }
)
return response.data
},
async setEnabled(nodeId: string, enabled: boolean): Promise<void> {
const headers = await authHeader()
const payload: BulkSetEnabledPayload = {
node_ids: [nodeId],
enabled
}
await partnerNodesApiClient.patch(
api.apiURL('/workspace/partner-nodes'),
payload,
{ headers }
)
},
async setEnabledBulk(nodeIds: string[], enabled: boolean): Promise<void> {
const headers = await authHeader()
const payload: BulkSetEnabledPayload = { node_ids: nodeIds, enabled }
await partnerNodesApiClient.patch(
api.apiURL('/workspace/partner-nodes'),
payload,
{ headers }
)
},
async setAutoEnableNew(autoEnableNew: boolean): Promise<void> {
const headers = await authHeader()
const payload: SetAutoEnablePayload = { auto_enable_new: autoEnableNew }
await partnerNodesApiClient.patch(
api.apiURL('/workspace/partner-nodes'),
payload,
{ headers }
)
}
}

View File

@@ -37,6 +37,11 @@ export interface Member {
// billing lifecycle actions (cancel / reactivate / downgrade).
// Optional: the cloud OpenAPI does not carry this field yet.
is_original_owner?: boolean
// Last time the member ran or interacted with the workspace, and the credits
// they've consumed in the current billing cycle. Optional: the cloud OpenAPI
// does not carry these fields yet.
last_active_at?: string | null
credits_used_this_month?: number
}
interface PaginationInfo {
@@ -244,6 +249,7 @@ export type BillingSubscriptionStatus =
| 'scheduled'
| 'ended'
| 'canceled'
| 'paused'
export type BillingStatus =
| 'awaiting_payment_method'

View File

@@ -59,7 +59,7 @@
<!-- Credits Section -->
<div class="flex items-center gap-2 px-4 py-2">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--coins] text-sm text-amber-400" />
<Skeleton
v-if="isLoadingBalance"
width="4rem"

View File

@@ -129,7 +129,7 @@
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
</span>
<div class="flex flex-row items-center gap-1">
<i class="icon-[lucide--component] text-sm text-amber-400" />
<i class="icon-[lucide--coins] size-4 text-amber-400" />
<span
class="font-inter text-sm/normal font-bold text-base-foreground"
>

View File

@@ -3,7 +3,7 @@
<!-- Loading state while subscription is being set up -->
<div
v-if="isSettingUp"
class="rounded-2xl border border-interface-stroke p-6"
class="rounded-2xl border border-interface-stroke/60 p-6"
>
<div class="flex items-center gap-2 py-4 text-muted-foreground">
<i class="pi pi-spin pi-spinner" />
@@ -14,7 +14,7 @@
<!-- Billing data still loading: avoid rendering a false Free/$0 plan -->
<div
v-else-if="isLoading && !subscription"
class="rounded-2xl border border-interface-stroke p-6"
class="rounded-2xl border border-interface-stroke/60 p-6"
>
<div class="flex items-center gap-2 py-4 text-muted-foreground">
<i class="pi pi-spin pi-spinner" />
@@ -25,7 +25,7 @@
<!-- Billing fetch failed: offer retry rather than a misleading Free plan -->
<div
v-else-if="error && !subscription"
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke p-6"
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke/60 p-6"
>
<div class="flex items-center gap-2 text-text-secondary">
<i class="pi pi-exclamation-circle text-danger" />
@@ -67,7 +67,7 @@
</div>
</div>
<div class="rounded-2xl border border-interface-stroke p-6">
<div class="rounded-2xl border border-interface-stroke/60 p-6">
<div>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"
@@ -439,11 +439,13 @@ const subscriptionTierName = computed(() => {
: baseName
})
const planDisplayName = computed(() =>
isInPersonalWorkspace.value
? subscriptionTierName.value
const planDisplayName = computed(() => {
if (isInPersonalWorkspace.value) return subscriptionTierName.value
// 'ENTERPRISE' is a wire tier not yet in the generated SubscriptionTier union.
return (subscription.value?.tier as string | null) === 'ENTERPRISE'
? t('subscription.enterprisePlanName')
: t('subscription.teamPlanName')
)
})
const tierKey = computed(() => {
const tier = subscription.value?.tier

View File

@@ -86,7 +86,7 @@
@max-reached="showCeilingWarning = true"
>
<template #prefix>
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
</template>
</FormattedNumberStepper>
</div>
@@ -98,7 +98,7 @@
v-if="isBelowMin"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
>
<i class="icon-[lucide--component] size-4" />
<i class="icon-[lucide--coins] size-4" />
{{
$t('credits.topUp.minRequired', {
credits: formatNumber(usdToCredits(MIN_AMOUNT))
@@ -109,7 +109,7 @@
v-if="showCeilingWarning"
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
>
<i class="icon-[lucide--component] size-4" />
<i class="icon-[lucide--coins] size-4" />
{{
$t('credits.topUp.maxAllowed', {
credits: formatNumber(usdToCredits(MAX_AMOUNT))

View File

@@ -1,22 +1,37 @@
<template>
<div
class="flex aspect-square size-8 items-center justify-center rounded-md text-base font-semibold text-white"
:style="{
background: gradient,
textShadow: '0 1px 2px rgba(0, 0, 0, 0.2)'
}"
:class="
cn(
'flex aspect-square size-8 items-center justify-center overflow-hidden rounded-md text-base font-semibold text-white',
$attrs.class as string
)
"
:style="imageUrl ? undefined : { background: gradient, textShadow }"
>
{{ letter }}
<img
v-if="imageUrl"
:src="imageUrl"
:alt="workspaceName"
class="size-full object-cover"
/>
<template v-else>{{ letter }}</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { workspaceName } = defineProps<{
import { cn } from '@comfyorg/tailwind-utils'
defineOptions({ inheritAttrs: false })
const { workspaceName, imageUrl } = defineProps<{
workspaceName: string
imageUrl?: string
}>()
const textShadow = '0 1px 2px rgba(0, 0, 0, 0.2)'
const letter = computed(() => workspaceName?.charAt(0)?.toUpperCase() ?? '?')
const gradient = computed(() => {

View File

@@ -56,7 +56,7 @@ describe('ChangeMemberRoleDialogContent', () => {
mockChangeMemberRole.mockResolvedValue(undefined)
})
it('shows promote copy and confirms with Make owner', async () => {
it('shows promote copy and confirms with Make admin', async () => {
const { user } = renderDialog('owner')
expect(

View File

@@ -64,6 +64,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
@@ -80,7 +81,11 @@ const workspaceName = ref('')
const isValidName = computed(() => {
const name = workspaceName.value.trim()
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
return (
name.length >= 1 &&
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
safeNameRegex.test(name)
)
})
function onCancel() {

View File

@@ -58,6 +58,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
import { useDialogStore } from '@/stores/dialogStore'
const { t } = useI18n()
@@ -70,7 +71,11 @@ const newWorkspaceName = ref(workspaceStore.workspaceName)
const isValidName = computed(() => {
const name = newWorkspaceName.value.trim()
const safeNameRegex = /^[a-zA-Z0-9][a-zA-Z0-9\s\-_'.,()&+]*$/
return name.length >= 1 && name.length <= 50 && safeNameRegex.test(name)
return (
name.length >= 1 &&
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
safeNameRegex.test(name)
)
})
function onCancel() {

View File

@@ -1,6 +1,6 @@
<template>
<div
class="flex w-full max-w-lg flex-col rounded-2xl border border-border-default bg-base-background"
class="flex w-132 max-w-full flex-col rounded-2xl border border-border-default bg-base-background"
>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
@@ -35,7 +35,7 @@
:value="email"
:class="
cn(
'rounded-full',
'rounded-full bg-tertiary-background-hover',
!EMAIL_REGEX.test(email) && 'bg-danger/20 text-danger'
)
"

View File

@@ -0,0 +1,54 @@
<template>
<div
class="flex w-full max-w-[400px] flex-col rounded-2xl border border-border-default bg-base-background"
>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">{{ title }}</h2>
<button
class="focus-visible:ring-secondary-foreground cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground focus-visible:ring-1 focus-visible:outline-none"
:aria-label="$t('g.close')"
@click="close"
>
<i class="pi pi-times size-4" />
</button>
</div>
<div class="p-4">
<p class="m-0 text-sm text-muted-foreground">{{ message }}</p>
</div>
<div class="flex items-center justify-end gap-2 p-4">
<Button variant="muted-textonly" @click="close">
{{ $t('g.close') }}
</Button>
<Button variant="secondary" size="lg" @click="requestMore">
{{ $t('workspacePanel.requestMore') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
import { useDialogStore } from '@/stores/dialogStore'
const { dialogKey, onRequestMore } = defineProps<{
dialogKey: string
title: string
message: string
onRequestMore: () => void
}>()
const dialogStore = useDialogStore()
function close() {
dialogStore.closeDialog({ key: dialogKey })
}
function requestMore() {
onRequestMore()
close()
}
</script>

View File

@@ -232,7 +232,7 @@ describe('TeamWorkspacesDialogContent', () => {
expect(findCreateButton(container)).toBeDisabled()
})
it('disables create button for name exceeding 50 characters', async () => {
it('disables create button for name exceeding the character limit', async () => {
const { container, user } = mountComponent()
const input = container.querySelector(
'#workspace-name-input'

View File

@@ -145,6 +145,7 @@ import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfil
import { useWorkspaceSwitch } from '@/platform/workspace/composables/useWorkspaceSwitch'
import { useWorkspaceTierLabel } from '@/platform/workspace/composables/useWorkspaceTierLabel'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
import { useDialogStore } from '@/stores/dialogStore'
const { onConfirm } = defineProps<{
@@ -178,7 +179,11 @@ const tierLabels = computed(
const isValidName = computed(() => {
const name = workspaceName.value.trim()
return name.length >= 1 && name.length <= 50 && SAFE_NAME_REGEX.test(name)
return (
name.length >= 1 &&
name.length <= WORKSPACE_NAME_MAX_LENGTH &&
SAFE_NAME_REGEX.test(name)
)
})
function onCancel() {

View File

@@ -0,0 +1,30 @@
<template>
<div class="@container flex min-h-0 flex-1 flex-col gap-4">
<!-- TODO(DES-503): models governance adds a Models sub-tab strip here;
prototype in PR #13487 -->
<div
class="flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
>
<span class="min-w-0 flex-1 text-sm font-medium">
{{ $t('workspacePanel.allowlist.tabs.partnerNodes') }}
</span>
<SearchInput
v-model="searchQuery"
:placeholder="$t('workspacePanel.partnerNodes.searchPlaceholder')"
size="lg"
class="w-full @2xl:w-64"
/>
</div>
<PartnerNodesPanelContent :search="searchQuery" />
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import PartnerNodesPanelContent from '@/platform/workspace/components/dialogs/settings/PartnerNodesPanelContent.vue'
const searchQuery = ref('')
</script>

View File

@@ -0,0 +1,167 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import BillingStatusBanner from './BillingStatusBanner.vue'
const mockManageSubscription = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
const mockHandleResubscribe = vi.fn()
const mockBillingStatus = ref<'payment_failed' | null>(null)
const mockIsPaused = ref(true)
const mockIsActiveSubscription = ref(true)
const mockSubscription = ref<{
hasFunds: boolean
isCancelled: boolean
endDate?: string
}>({ hasFunds: true, isCancelled: false })
const mockRenewalDate = ref<string | null>(null)
const mockPermissions = ref({ canManageSubscription: true })
const mockIsInPersonalWorkspace = ref(false)
let mockActiveWorkspaceId: string | null = 'team-1'
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
billingStatus: mockBillingStatus,
isPaused: mockIsPaused,
isActiveSubscription: mockIsActiveSubscription,
subscription: mockSubscription,
renewalDate: mockRenewalDate,
manageSubscription: mockManageSubscription
})
}))
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => ({
useWorkspaceUI: () => ({
permissions: mockPermissions,
isInPersonalWorkspace: mockIsInPersonalWorkspace
})
}))
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => ({
useTeamWorkspaceStore: () => ({
activeWorkspaceId: mockActiveWorkspaceId
})
}))
vi.mock('@/platform/workspace/composables/useResubscribe', () => ({
useResubscribe: () => ({
isResubscribing: ref(false),
handleResubscribe: mockHandleResubscribe
})
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
})
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
function renderComponent() {
return render(BillingStatusBanner, { global: { plugins: [i18n] } })
}
describe('BillingStatusBanner', () => {
beforeEach(() => {
vi.clearAllMocks()
sessionStorage.clear()
mockBillingStatus.value = null
mockIsPaused.value = true
mockIsActiveSubscription.value = true
mockSubscription.value = { hasFunds: true, isCancelled: false }
mockRenewalDate.value = null
mockPermissions.value = { canManageSubscription: true }
mockIsInPersonalWorkspace.value = false
mockActiveWorkspaceId = 'team-1'
})
it('prioritizes a paused subscription and opens payment management', async () => {
const user = userEvent.setup()
mockBillingStatus.value = 'payment_failed'
renderComponent()
expect(screen.getByText('Subscription paused')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Update payment' }))
expect(mockManageSubscription).toHaveBeenCalledOnce()
})
it('shows paused members non-actionable guidance', () => {
mockPermissions.value = { canManageSubscription: false }
renderComponent()
expect(
screen.getByText(
"This workspace's subscription is paused. Your workspace admins need to update the payment method."
)
).toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('opens payment management for a failed payment', async () => {
const user = userEvent.setup()
mockIsPaused.value = false
mockBillingStatus.value = 'payment_failed'
mockRenewalDate.value = '2026-08-15T00:00:00Z'
renderComponent()
expect(screen.getByText('Payment declined')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Update payment' }))
expect(mockManageSubscription).toHaveBeenCalledOnce()
})
it('opens top-up and dismisses an out-of-credits banner', async () => {
const user = userEvent.setup()
mockIsPaused.value = false
mockSubscription.value = { hasFunds: false, isCancelled: false }
mockRenewalDate.value = '2026-08-15T00:00:00Z'
const { unmount } = renderComponent()
expect(screen.getByText('Out of credits')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Add credits' }))
expect(mockShowTopUpCreditsDialog).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: 'Dismiss' }))
expect(screen.queryByRole('status')).not.toBeInTheDocument()
unmount()
renderComponent()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
it('reactivates a plan that is ending', async () => {
const user = userEvent.setup()
mockIsPaused.value = false
mockSubscription.value = {
hasFunds: true,
isCancelled: true,
endDate: '2026-08-31T00:00:00Z'
}
renderComponent()
expect(screen.getByText(/Your team plan ends on/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Reactivate plan' }))
expect(mockHandleResubscribe).toHaveBeenCalledOnce()
})
it('hides billing banners in personal workspaces', () => {
mockIsInPersonalWorkspace.value = true
renderComponent()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,188 @@
<template>
<div
v-if="banner"
role="status"
class="flex flex-col gap-3 rounded-2xl border border-interface-stroke/60 bg-base-background p-4 @2xl:flex-row @2xl:items-center @2xl:gap-2"
>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<i
:class="
cn(
'size-4 shrink-0',
// Muted circle for the calm plan-ending notice; amber triangle for
// every action-needed problem (paused, payment failed, out of credits).
banner.kind === 'ending'
? 'icon-[lucide--circle-alert] text-muted-foreground'
: 'icon-[lucide--triangle-alert] text-warning-background'
)
"
/>
<span class="text-sm text-base-foreground">{{ banner.title }}</span>
</div>
<p class="m-0 pl-6 text-sm text-muted-foreground">{{ banner.body }}</p>
</div>
<div
v-if="banner.showAction"
class="flex shrink-0 flex-wrap items-center gap-2 pl-6 @2xl:pl-0"
>
<slot name="actions" />
<template v-if="banner.kind === 'outOfCredits'">
<Button variant="textonly" size="lg" @click="dismiss">
{{ $t('workspacePanel.billingStatus.outOfCredits.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="handleAddCredits">
{{ $t('workspacePanel.billingStatus.outOfCredits.addCredits') }}
</Button>
</template>
<Button
v-else-if="banner.kind === 'ending'"
variant="secondary"
size="lg"
:loading="isResubscribing"
@click="handleResubscribe"
>
{{ $t('workspacePanel.billingStatus.ending.reactivate') }}
</Button>
<Button v-else variant="inverted" size="lg" @click="manageSubscription">
{{ $t('workspacePanel.billingStatus.updatePayment') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useSessionStorage } from '@vueuse/core'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@comfyorg/tailwind-utils'
const { t, d } = useI18n()
const {
billingStatus,
isPaused,
isActiveSubscription,
subscription,
renewalDate,
manageSubscription
} = useBillingContext()
const { permissions, isInPersonalWorkspace } = useWorkspaceUI()
const dialogService = useDialogService()
const { isResubscribing, handleResubscribe } = useResubscribe()
const workspaceStore = useTeamWorkspaceStore()
const dismissedWorkspaceIds = useSessionStorage<string[]>(
WORKSPACE_STORAGE_KEYS.DISMISSED_BILLING_BANNERS,
[]
)
const canManage = computed(() => permissions.value.canManageSubscription)
const cycleResetDate = computed(() => {
const raw = renewalDate.value
return raw ? d(new Date(raw), { month: 'short', day: 'numeric' }) : ''
})
const planEndDate = computed(() => {
const raw = subscription.value?.endDate
return raw
? d(new Date(raw), { year: 'numeric', month: 'long', day: 'numeric' })
: ''
})
// Out of credits: an active, non-paused team that has exhausted its balance.
// Paused takes over this slot (see priority below). Dismissible for the session.
const dismissed = computed(() => {
const workspaceId = workspaceStore.activeWorkspaceId
return !!workspaceId && dismissedWorkspaceIds.value.includes(workspaceId)
})
const isOutOfCredits = computed(
() =>
isActiveSubscription.value &&
!isPaused.value &&
subscription.value?.hasFunds === false
)
// A cancelled-but-still-active plan is winding down to its end date. Unlike the
// states above it's a calm, owner-initiated notice (not a problem), so it sits
// last and reads with the muted circle icon and a low-key secondary action.
const isEnding = computed(
() =>
isActiveSubscription.value &&
!isPaused.value &&
(subscription.value?.isCancelled ?? false) &&
planEndDate.value !== ''
)
// One status banner slot across every workspace tab, in priority order: paused →
// payment-failure warning → out of credits → plan ending. All owner/admin-only
// (members can't act on any of them).
const banner = computed(() => {
if (isInPersonalWorkspace.value) return null
if (isPaused.value) {
return {
kind: 'paused' as const,
title: t('workspacePanel.billingStatus.paused.title'),
body: canManage.value
? t('workspacePanel.billingStatus.paused.body')
: t('workspacePanel.billingStatus.paused.memberBody'),
showAction: canManage.value
}
}
if (billingStatus.value === 'payment_failed' && canManage.value) {
return {
kind: 'warning' as const,
title: t('workspacePanel.billingStatus.warning.title'),
body: t('workspacePanel.billingStatus.warning.body', {
date: cycleResetDate.value
}),
showAction: true
}
}
if (isOutOfCredits.value && canManage.value && !dismissed.value) {
return {
kind: 'outOfCredits' as const,
title: t('workspacePanel.billingStatus.outOfCredits.title'),
body: cycleResetDate.value
? t('workspacePanel.billingStatus.outOfCredits.body', {
date: cycleResetDate.value
})
: t('workspacePanel.billingStatus.outOfCredits.bodyNoDate'),
showAction: true
}
}
if (isEnding.value && canManage.value) {
return {
kind: 'ending' as const,
title: t('workspacePanel.billingStatus.ending.title', {
date: planEndDate.value
}),
body: t('workspacePanel.billingStatus.ending.body'),
showAction: true
}
}
return null
})
function dismiss() {
const workspaceId = workspaceStore.activeWorkspaceId
if (!workspaceId || dismissed.value) return
dismissedWorkspaceIds.value = [...dismissedWorkspaceIds.value, workspaceId]
}
function handleAddCredits() {
void dialogService.showTopUpCreditsDialog()
}
</script>

View File

@@ -1,91 +0,0 @@
<template>
<div
:data-testid="`member-row-${member.id}`"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
isSingleSeatPlan ? 'grid-cols-1' : gridCols,
striped && 'bg-secondary-background/50'
)
"
>
<div class="flex items-center gap-3">
<UserAvatar
class="size-8"
:photo-url="isCurrentUser ? photoUrl : undefined"
:pt:icon:class="{ 'text-xl!': !isCurrentUser || !photoUrl }"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ member.name }}
<span v-if="isCurrentUser" class="text-muted-foreground">
({{ $t('g.you') }})
</span>
</span>
<span class="text-sm text-muted-foreground">
{{ member.email }}
</span>
</div>
</div>
<span
v-if="showRoleColumn && !isSingleSeatPlan"
class="text-right text-sm text-muted-foreground"
>
{{
member.role === 'owner'
? $t('workspaceSwitcher.roleOwner')
: $t('workspaceSwitcher.roleMember')
}}
</span>
<div
v-if="canManageMembers && !isSingleSeatPlan"
class="flex items-center justify-end"
>
<DropdownMenu
v-if="!isCurrentUser && !isOriginalOwner"
:entries="menuItems"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
</Button>
</template>
</DropdownMenu>
</div>
</div>
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import UserAvatar from '@/components/common/UserAvatar.vue'
import Button from '@/components/ui/button/Button.vue'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const {
showRoleColumn = false,
canManageMembers = false,
isSingleSeatPlan = false,
isOriginalOwner = false,
striped = false,
menuItems = []
} = defineProps<{
member: WorkspaceMember
isCurrentUser: boolean
photoUrl?: string
gridCols: string
showRoleColumn?: boolean
canManageMembers?: boolean
isSingleSeatPlan?: boolean
isOriginalOwner?: boolean
striped?: boolean
menuItems?: MenuItem[]
}>()
</script>

View File

@@ -0,0 +1,117 @@
<template>
<TableRow
:data-testid="`member-row-${member.id}`"
class="group hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
>
<TableCell>
<div class="flex items-center gap-3">
<span
class="flex size-8 shrink-0 items-center justify-center rounded-full"
:style="{
backgroundColor: userBadgeColor(member.name || member.email)
}"
>
<span class="text-sm font-bold text-base-foreground">
{{ initial }}
</span>
</span>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ member.name }}
<span v-if="isCurrentUser" class="text-muted-foreground">
({{ $t('g.you') }})
</span>
</span>
<span class="truncate text-sm text-muted-foreground">
{{ member.email }}
</span>
</div>
</div>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{{ $t(roleLabelKey(member.role, isOriginalOwner)) }}
</TableCell>
<TableCell v-if="canManageMembers" class="text-sm text-muted-foreground">
{{ lastActivityLabel }}
</TableCell>
<TableCell
v-if="canManageMembers"
class="text-right text-sm text-muted-foreground tabular-nums"
>
{{ creditsLabel }}
</TableCell>
<TableCell v-if="canManageMembers" class="text-right" @click.stop>
<DropdownMenu
v-if="showMenu"
:entries="menuItems"
:modal="false"
content-class="min-w-44"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
</Button>
</template>
</DropdownMenu>
</TableCell>
</TableRow>
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
import { roleLabelKey } from '@/platform/workspace/utils/roleLabels'
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
const {
member,
isCurrentUser,
canManageMembers = false,
isOriginalOwner = false,
menuItems = []
} = defineProps<{
member: WorkspaceMember
isCurrentUser: boolean
canManageMembers?: boolean
isOriginalOwner?: boolean
menuItems?: MenuItem[]
}>()
const { t } = useI18n()
const initial = computed(() =>
(member.name || member.email).charAt(0).toUpperCase()
)
// The creator and the current user can't be managed from their own row.
const showMenu = computed(
() => canManageMembers && !isCurrentUser && !isOriginalOwner
)
const lastActivityLabel = computed(() => {
if (!member.lastActivity) return t('workspacePanel.members.activity.never')
return formatRelativeTime(member.lastActivity, new Date(), {
justNow: t('workspacePanel.members.activity.justNow'),
minutesAgo: (n) => t('workspacePanel.members.activity.minutesAgo', { n }),
hoursAgo: (n) => t('workspacePanel.members.activity.hoursAgo', { n }),
daysAgo: (n) => t('workspacePanel.members.activity.daysAgo', n)
})
})
const creditsLabel = computed(() =>
(member.creditsUsedThisMonth ?? 0).toLocaleString()
)
</script>

View File

@@ -1,8 +1,7 @@
import { render, screen, within } from '@testing-library/vue'
import { render, screen, waitFor, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Slots } from 'vue'
import { computed, h, ref } from 'vue'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import MembersPanelContent from './MembersPanelContent.vue'
@@ -18,6 +17,7 @@ const mockMemberMenuItems = vi.fn(() => [])
const mockShowTeamPlans = vi.fn()
const mockToggleSort = vi.fn()
const mockHandleInviteMember = vi.fn()
const mockFetchBalance = vi.fn()
const {
mockMembers,
@@ -27,13 +27,14 @@ const {
mockFilteredPendingInvites,
mockIsPersonalWorkspace,
mockIsOnTeamPlan,
mockHasMultipleMembers,
mockShowSearch,
mockShowViewTabs,
mockShowInviteButton,
mockIsInviteDisabled,
mockActiveView,
mockSearchQuery,
mockSortField,
mockSortDirection,
mockPermissions,
mockUiConfig
} = vi.hoisted(() => {
@@ -44,7 +45,6 @@ const {
mockMembers: ref<WorkspaceMember[]>([]),
mockPendingInvites: ref<PendingInvite[]>([]),
mockOriginalOwnerId: ref<string | null>(null),
mockHasMultipleMembers: ref(true),
mockShowSearch: ref(true),
mockShowViewTabs: ref(true),
mockShowInviteButton: ref(true),
@@ -55,39 +55,36 @@ const {
mockIsOnTeamPlan: ref(true),
mockActiveView: ref<'active' | 'pending'>('active'),
mockSearchQuery: ref(''),
mockSortField: ref('role'),
mockSortDirection: ref('desc'),
mockPermissions: ref({
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canManageMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canTopUp: true
canManageMembers: true
}),
mockUiConfig: ref({
showMembersList: true,
showPendingTab: true,
showSearch: true,
showRoleColumn: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete' as 'leave' | 'delete' | null,
workspaceMenuDisabledTooltip: null as string | null
showSearch: true
})
}
})
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ isPaused: computed(() => false) })
}))
vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
useMembersPanel: () => ({
searchQuery: mockSearchQuery,
activeView: mockActiveView,
maxSeats: computed(() => 20),
sortField: mockSortField,
sortDirection: mockSortDirection,
maxSeats: computed(() => 50),
memberCount: computed(() => mockMembers.value.length),
isOnTeamPlan: mockIsOnTeamPlan,
hasMultipleMembers: mockHasMultipleMembers,
hasLapsedTeamPlan: computed(() => false),
showSearch: mockShowSearch,
showViewTabs: mockShowViewTabs,
showInviteButton: mockShowInviteButton,
@@ -104,7 +101,6 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
})),
filteredMembers: mockFilteredMembers,
filteredPendingInvites: mockFilteredPendingInvites,
memberMenuItems: mockMemberMenuItems,
memberMenus: computed(
() =>
new Map(
@@ -112,28 +108,21 @@ vi.mock('@/platform/workspace/composables/useMembersPanel', () => ({
)
),
isPersonalWorkspace: mockIsPersonalWorkspace,
members: mockMembers,
pendingInvites: mockPendingInvites,
permissions: mockPermissions,
uiConfig: mockUiConfig,
userPhotoUrl: ref(null),
fetchBalance: mockFetchBalance,
isCurrentUser: (m: WorkspaceMember) =>
m.email.toLowerCase() === 'owner@example.com',
isOriginalOwner: (m: WorkspaceMember) => m.id === mockOriginalOwnerId.value,
toggleSort: mockToggleSort,
showTeamPlans: mockShowTeamPlans,
handleResendInvite: mockHandleResendInvite,
handleRevokeInvite: mockHandleRevokeInvite,
handleRemoveMember: vi.fn(),
handleChangeRole: vi.fn()
handleRevokeInvite: mockHandleRevokeInvite
})
}))
vi.mock('@/components/button/MoreButton.vue', () => ({
default: (_: unknown, { slots }: { slots: Slots }) =>
h('div', slots.default?.({ close: () => {} }))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -157,6 +146,15 @@ const SearchInputStub = {
emits: ['update:modelValue']
}
// Render the trigger slot (carries the g.moreOptions button) plus each entry as
// a flat button, so menu items are assertable without opening a real overlay.
const DropdownMenuStub = {
name: 'DropdownMenu',
props: ['entries', 'modal', 'contentClass'],
template:
'<div><slot name="button" /><button v-for="e in (entries || [])" :key="e.label" :aria-label="e.label" @click="e.command && e.command()">{{ e.label }}</button></div>'
}
function renderComponent() {
return render(MembersPanelContent, {
global: {
@@ -164,8 +162,9 @@ function renderComponent() {
stubs: {
Button: ButtonStub,
SearchInput: SearchInputStub,
DropdownMenu: DropdownMenuStub,
UserAvatar: true,
WorkspaceMenuButton: true
BillingStatusBanner: true
},
directives: { tooltip: () => {} }
}
@@ -199,6 +198,7 @@ function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
describe('MembersPanelContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFetchBalance.mockResolvedValue(undefined)
mockMemberMenuItems.mockReturnValue([])
mockMembers.value = []
mockPendingInvites.value = []
@@ -207,7 +207,6 @@ describe('MembersPanelContent', () => {
mockFilteredPendingInvites.value = []
mockIsPersonalWorkspace.value = false
mockIsOnTeamPlan.value = true
mockHasMultipleMembers.value = true
mockShowSearch.value = true
mockShowViewTabs.value = true
mockShowInviteButton.value = true
@@ -215,41 +214,41 @@ describe('MembersPanelContent', () => {
mockActiveView.value = 'active'
mockSearchQuery.value = ''
mockPermissions.value = {
canViewOtherMembers: true,
canViewPendingInvites: true,
canInviteMembers: true,
canManageInvites: true,
canManageMembers: true,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: true,
canTopUp: true
canManageMembers: true
}
mockUiConfig.value = {
showMembersList: true,
showPendingTab: true,
showSearch: true,
showRoleColumn: true,
membersGridCols: 'grid-cols-[50%_40%_10%]',
pendingGridCols: 'grid-cols-[50%_20%_20%_10%]',
headerGridCols: 'grid-cols-[50%_40%_10%]',
showEditWorkspaceMenuItem: true,
workspaceMenuAction: 'delete',
workspaceMenuDisabledTooltip: null
showSearch: true
}
})
it('handles billing balance load failures', async () => {
const error = new Error('network failure')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockFetchBalance.mockRejectedValue(error)
renderComponent()
await waitFor(() =>
expect(consoleError).toHaveBeenCalledWith(
'Failed to load workspace billing balance',
error
)
)
consoleError.mockRestore()
})
describe('personal workspace', () => {
beforeEach(() => {
mockIsPersonalWorkspace.value = true
mockIsOnTeamPlan.value = false
mockHasMultipleMembers.value = false
mockShowSearch.value = false
mockShowViewTabs.value = false
mockIsInviteDisabled.value = true
mockUiConfig.value.showMembersList = false
mockUiConfig.value.showSearch = false
mockUiConfig.value.showPendingTab = false
})
it('shows the upsell banner below the members card', () => {
@@ -275,7 +274,7 @@ describe('MembersPanelContent', () => {
})
})
describe('team workspace - member list', () => {
describe('team workspace - member table', () => {
it('shows the Role column header and member roles', () => {
mockFilteredMembers.value = [
createMember({ role: 'owner', email: 'boss@test.com' }),
@@ -285,18 +284,47 @@ describe('MembersPanelContent', () => {
expect(
screen.getByText('workspacePanel.members.columns.role')
).toBeTruthy()
expect(screen.getByText('workspaceSwitcher.roleOwner')).toBeTruthy()
expect(screen.getByText('workspaceSwitcher.roleAdmin')).toBeTruthy()
expect(screen.getByText('workspaceSwitcher.roleMember')).toBeTruthy()
})
it('shows the Last activity and Credits columns', () => {
mockFilteredMembers.value = [createMember()]
renderComponent()
expect(
screen.getByText('workspacePanel.members.columns.lastActivity')
).toBeTruthy()
expect(
screen.getByText('workspacePanel.members.columns.creditsUsed')
).toBeTruthy()
})
it('renders the monthly credits for a member', () => {
mockFilteredMembers.value = [createMember({ creditsUsedThisMonth: 6532 })]
renderComponent()
expect(screen.getByText('6,532')).toBeTruthy()
})
it('labels the original owner as Owner and other owners as Admin', () => {
mockOriginalOwnerId.value = 'creator-1'
mockFilteredMembers.value = [
createMember({
id: 'creator-1',
email: 'creator@test.com',
role: 'owner',
isOriginalOwner: true
}),
createMember({ id: '2', email: 'admin@test.com', role: 'owner' })
]
renderComponent()
expect(screen.getByText('workspaceSwitcher.roleOwner')).toBeTruthy()
expect(screen.getByText('workspaceSwitcher.roleAdmin')).toBeTruthy()
})
it('renders filtered members', () => {
mockFilteredMembers.value = [
createMember({ name: 'Alice', email: 'alice@test.com' }),
createMember({
id: '2',
name: 'Bob',
email: 'bob@test.com'
})
createMember({ id: '2', name: 'Bob', email: 'bob@test.com' })
]
renderComponent()
expect(screen.getByText('Alice')).toBeTruthy()
@@ -388,34 +416,20 @@ describe('MembersPanelContent', () => {
describe('member role', () => {
beforeEach(() => {
mockPermissions.value = {
canViewOtherMembers: true,
canViewPendingInvites: false,
canViewPendingInvites: true,
canInviteMembers: false,
canManageInvites: false,
canManageMembers: false,
canLeaveWorkspace: true,
canAccessWorkspaceMenu: true,
canManageSubscription: false,
canTopUp: false
canManageMembers: false
}
mockUiConfig.value.showPendingTab = false
mockUiConfig.value.showPendingTab = true
})
it('hides the pending tab button', () => {
it('shows the pending tab button (view-only)', () => {
mockPendingInvites.value = [createInvite()]
renderComponent()
expect(
screen.queryByText(/workspacePanel\.members\.tabs\.pendingCount/)
).toBeNull()
})
it('does not show the pending invites header', () => {
mockActiveView.value = 'pending'
mockPendingInvites.value = [createInvite()]
renderComponent()
expect(
screen.queryByText(/workspacePanel\.members\.pendingInvitesCount/)
).toBeNull()
screen.getByText(/workspacePanel\.members\.tabs\.pendingCount/)
).toBeTruthy()
})
it('shows no action menus on member rows', () => {
@@ -451,15 +465,6 @@ describe('MembersPanelContent', () => {
).toBeNull()
})
it('opens subscription dialog on upgrade click', async () => {
renderComponent()
const upgradeBtn = screen.getByRole('button', {
name: /workspacePanel\.members\.upgradeToTeam/
})
await userEvent.click(upgradeBtn)
expect(mockShowTeamPlans).toHaveBeenCalled()
})
it('hides search input', () => {
renderComponent()
expect(screen.queryByRole('textbox')).toBeNull()
@@ -472,17 +477,15 @@ describe('MembersPanelContent', () => {
})
describe('contact us footer', () => {
it('opens discord in a new tab for team workspaces on a team plan', async () => {
it('opens the team-plan request form in a new tab for team workspaces on a team plan', async () => {
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
renderComponent()
expect(
screen.getByText('workspacePanel.members.needMoreMembers')
).toBeTruthy()
expect(screen.getByText(/needMoreMembers/)).toBeTruthy()
await userEvent.click(
screen.getByText('workspacePanel.members.contactUs')
)
expect(openSpy).toHaveBeenCalledWith(
'https://www.comfy.org/discord',
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests',
'_blank',
'noopener,noreferrer'
)
@@ -496,16 +499,12 @@ describe('MembersPanelContent', () => {
})
})
describe('member count display', () => {
it('shows member count header for team workspace', () => {
mockFilteredMembers.value = [
createMember({ id: '1' }),
createMember({ id: '2' })
]
mockMembers.value = mockFilteredMembers.value
describe('member count tab', () => {
it('shows the members count tab for team workspace', () => {
mockMembers.value = [createMember({ id: '1' }), createMember({ id: '2' })]
renderComponent()
expect(
screen.getByText(/workspacePanel\.members\.membersCount/)
screen.getByText(/workspacePanel\.members\.tabs\.membersCount/)
).toBeTruthy()
})
})
@@ -540,10 +539,7 @@ describe('MembersPanelContent', () => {
mockShowViewTabs.value = false
renderComponent()
expect(
screen.queryByText('workspacePanel.members.tabs.active')
).toBeNull()
expect(
screen.queryByText('workspacePanel.members.columns.role')
screen.queryByText(/workspacePanel\.members\.tabs\.pendingCount/)
).toBeNull()
})
})

View File

@@ -1,209 +1,217 @@
<template>
<div class="grow overflow-auto pt-6">
<div class="@container flex min-h-0 flex-1 flex-col gap-4 pb-6">
<!-- Header: tabs (left) + search / invite (right), above the card -->
<div
class="border-inter flex size-full flex-col gap-2 rounded-2xl border border-interface-stroke p-6"
class="flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl:gap-9"
>
<!-- Section Header -->
<div class="flex w-full items-center gap-9">
<div class="flex min-w-0 flex-1 items-baseline gap-2">
<span class="text-base font-semibold text-base-foreground">
<template v-if="activeView === 'active'">
<template v-if="isOnTeamPlan && !isPersonalWorkspace">
{{
$t('workspacePanel.members.membersCount', {
count: members.length,
maxSeats: maxSeats
})
}}
</template>
<template v-else>
{{ $t('workspacePanel.members.header') }}
</template>
</template>
<template v-else-if="permissions.canViewPendingInvites">
{{
$t(
'workspacePanel.members.pendingInvitesCount',
pendingInvites.length
)
}}
</template>
</span>
</div>
<div class="flex items-center gap-2">
<SearchInput
v-if="showSearch"
v-model="searchQuery"
:placeholder="$t('workspacePanel.members.searchPlaceholder')"
size="lg"
class="w-64"
/>
<div class="flex min-w-0 flex-1 items-center gap-2">
<template v-if="showViewTabs">
<Button
v-if="showInviteButton"
v-tooltip="
inviteTooltip
? { value: inviteTooltip, showDelay: 0 }
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
"
variant="secondary"
:variant="activeView === 'active' ? 'secondary' : 'muted-textonly'"
size="lg"
:disabled="isInviteDisabled"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
@click="activeView = 'active'"
>
{{ $t('workspacePanel.invite') }}
<i class="pi pi-plus text-sm" />
{{ $t('workspacePanel.members.tabs.membersCount', memberCount) }}
</Button>
<WorkspaceMenuButton v-if="permissions.canAccessWorkspaceMenu" />
</div>
<Button
v-if="uiConfig.showPendingTab"
:variant="activeView === 'pending' ? 'secondary' : 'muted-textonly'"
size="lg"
@click="activeView = 'pending'"
>
{{
pendingInvites.length > 0
? $t(
'workspacePanel.members.tabs.pendingCount',
pendingInvites.length
)
: $t('workspacePanel.members.tabs.pending')
}}
</Button>
</template>
<span v-else class="text-base font-normal text-base-foreground">
{{ $t('workspacePanel.members.tabs.membersCount', memberCount) }}
</span>
</div>
<!-- Members Content -->
<div class="flex min-h-0 flex-1 flex-col">
<!-- Table Header with Tab Buttons and Column Headers -->
<div
v-if="uiConfig.showMembersList && showViewTabs"
:class="
cn(
'grid w-full items-center py-2',
activeView === 'pending'
? uiConfig.pendingGridCols
: uiConfig.headerGridCols
)
<div class="flex w-full items-center gap-2 @2xl:w-auto">
<SearchInput
v-if="showSearch"
v-model="searchQuery"
:placeholder="$t('workspacePanel.members.searchPlaceholder')"
size="lg"
class="min-w-0 flex-1 @2xl:w-64 @2xl:flex-none"
/>
<Button
v-if="showInviteButton"
v-tooltip="
inviteTooltip
? { value: inviteTooltip, showDelay: 0 }
: { value: $t('workspacePanel.inviteMember'), showDelay: 300 }
"
variant="secondary"
size="lg"
class="shrink-0"
:disabled="isInviteDisabled"
:aria-label="$t('workspacePanel.inviteMember')"
@click="handleInviteMember"
>
<!-- Tab buttons in first column -->
<div class="flex items-center gap-2">
<Button
:variant="
activeView === 'active' ? 'secondary' : 'muted-textonly'
"
size="md"
@click="activeView = 'active'"
{{ $t('workspacePanel.invite') }}
<i class="icon-[lucide--plus] size-4" />
</Button>
</div>
</div>
<BillingStatusBanner />
<!-- Card: fills height, table scrolls inside -->
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
>
<Table v-if="activeView === 'active'" class="min-h-0 flex-1 px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead :aria-sort="ariaSort('email')">
<button :class="sortHeaderClass" @click="toggleSort('email')">
{{ $t('workspacePanel.members.columns.email') }}
<i :class="sortIcon('email')" />
</button>
</TableHead>
<TableHead
:class="permissions.canManageMembers ? 'w-40' : undefined"
:aria-sort="ariaSort('role')"
>
{{ $t('workspacePanel.members.tabs.active') }}
</Button>
<Button
v-if="uiConfig.showPendingTab"
:variant="
activeView === 'pending' ? 'secondary' : 'muted-textonly'
"
size="md"
@click="activeView = 'pending'"
<button :class="sortHeaderClass" @click="toggleSort('role')">
{{ $t('workspacePanel.members.columns.role') }}
<i :class="sortIcon('role')" />
</button>
</TableHead>
<TableHead
v-if="permissions.canManageMembers"
class="w-40"
:aria-sort="ariaSort('lastActivity')"
>
{{
$t(
'workspacePanel.members.tabs.pendingCount',
pendingInvites.length
)
}}
</Button>
</div>
<!-- Date column headers -->
<template v-if="activeView === 'pending'">
<Button
variant="muted-textonly"
size="sm"
class="justify-start"
@click="toggleSort('inviteDate')"
<button
:class="sortHeaderClass"
@click="toggleSort('lastActivity')"
>
{{ $t('workspacePanel.members.columns.lastActivity') }}
<i :class="sortIcon('lastActivity')" />
</button>
</TableHead>
<TableHead
v-if="permissions.canManageMembers"
class="w-64"
:aria-sort="ariaSort('credits')"
>
{{ $t('workspacePanel.members.columns.inviteDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<Button
variant="muted-textonly"
size="sm"
class="justify-start"
@click="toggleSort('expiryDate')"
>
{{ $t('workspacePanel.members.columns.expiryDate') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<div />
</template>
<button
:class="cn(sortHeaderClass, 'ml-auto')"
@click="toggleSort('credits')"
>
<i class="icon-[lucide--coins] size-4" />
{{ $t('workspacePanel.members.columns.creditsUsed') }}
<i :class="sortIcon('credits')" />
</button>
</TableHead>
<TableHead v-if="permissions.canManageMembers" class="w-12" />
</TableRow>
</TableHeader>
<TableBody>
<MemberTableRow
v-if="isPersonalWorkspace"
:member="personalWorkspaceMember"
:is-current-user="true"
/>
<template v-else>
<Button
variant="muted-textonly"
size="sm"
class="justify-end"
@click="toggleSort('role')"
>
{{ $t('workspacePanel.members.columns.role') }}
<i class="icon-[lucide--chevrons-up-down] size-4" />
</Button>
<!-- Empty cell for action column header (OWNER only) -->
<div v-if="permissions.canManageMembers" />
<MemberTableRow
v-for="member in filteredMembers"
:key="member.id"
:member="member"
:is-current-user="isCurrentUser(member)"
:can-manage-members="permissions.canManageMembers"
:is-original-owner="isOriginalOwner(member)"
:menu-items="memberMenus.get(member.id)"
/>
</template>
</div>
</TableBody>
</Table>
<!-- Members List -->
<div class="min-h-0 flex-1 overflow-y-auto">
<!-- Active Members -->
<template v-if="activeView === 'active'">
<!-- Personal Workspace: show only current user -->
<template v-if="isPersonalWorkspace">
<MemberListItem
:member="personalWorkspaceMember"
:is-current-user="true"
:photo-url="userPhotoUrl ?? undefined"
:grid-cols="uiConfig.membersGridCols"
/>
</template>
<!-- Team Workspace: sorted list -->
<template v-else>
<MemberListItem
v-for="(member, index) in filteredMembers"
:key="member.id"
:member="member"
:is-current-user="isCurrentUser(member)"
:photo-url="
isCurrentUser(member)
? (userPhotoUrl ?? undefined)
: undefined
"
:grid-cols="uiConfig.membersGridCols"
:show-role-column="
uiConfig.showRoleColumn && hasMultipleMembers
"
:can-manage-members="permissions.canManageMembers"
:is-single-seat-plan="!isOnTeamPlan"
:is-original-owner="isOriginalOwner(member)"
:striped="index % 2 === 1"
:menu-items="memberMenus.get(member.id)"
/>
</template>
</template>
<!-- Pending Invites -->
<PendingInvitesList
v-if="activeView === 'pending'"
:invites="filteredPendingInvites"
:grid-cols="uiConfig.pendingGridCols"
<Table v-else class="min-h-0 flex-1 px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead>
<span :class="sortHeaderClass">
{{ $t('workspacePanel.members.columns.email') }}
</span>
</TableHead>
<TableHead class="w-40" :aria-sort="ariaSort('inviteDate')">
<button
:class="sortHeaderClass"
@click="toggleSort('inviteDate')"
>
{{ $t('workspacePanel.members.columns.inviteDate') }}
<i :class="sortIcon('inviteDate')" />
</button>
</TableHead>
<TableHead class="w-40" :aria-sort="ariaSort('expiryDate')">
<button
:class="sortHeaderClass"
@click="toggleSort('expiryDate')"
>
{{ $t('workspacePanel.members.columns.expiryDate') }}
<i :class="sortIcon('expiryDate')" />
</button>
</TableHead>
<TableHead v-if="permissions.canManageInvites" class="w-12" />
</TableRow>
</TableHeader>
<TableBody>
<PendingInviteRow
v-for="invite in filteredPendingInvites"
:key="invite.id"
:invite="invite"
:can-manage="permissions.canManageInvites"
@resend="handleResendInvite"
@revoke="handleRevokeInvite"
/>
</div>
</div>
<TableRow
v-if="filteredPendingInvites.length === 0"
class="hover:bg-transparent"
>
<TableCell
:colspan="permissions.canManageInvites ? 4 : 3"
class="py-6 text-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.members.noInvites') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Upsell Banner -->
<MemberUpsellBanner
v-if="!isOnTeamPlan"
:reactivate="hasLapsedTeamPlan"
@show-plans="showTeamPlans()"
/>
<!-- Need More Members Footer -->
<div
v-if="isOnTeamPlan && !isPersonalWorkspace"
class="flex items-center pt-2"
class="flex h-8 items-center"
>
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.members.needMoreMembers') }}
{{ membersUsageLabel }}
<template v-if="permissions.canInviteMembers">
{{ $t('workspacePanel.members.needMoreMembers') }}
</template>
</p>
<Button
v-if="permissions.canInviteMembers"
variant="muted-textonly"
size="sm"
class="text-base-foreground"
size="md"
class="text-sm text-base-foreground"
@click="handleContactUs"
>
{{ $t('workspacePanel.members.contactUs') }}
@@ -215,21 +223,31 @@
<script setup lang="ts">
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
import Table from '@/components/ui/table/Table.vue'
import TableBody from '@/components/ui/table/TableBody.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableHead from '@/components/ui/table/TableHead.vue'
import TableHeader from '@/components/ui/table/TableHeader.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import { useExternalLink } from '@/composables/useExternalLink'
import MemberListItem from '@/platform/workspace/components/dialogs/settings/MemberListItem.vue'
import MemberTableRow from '@/platform/workspace/components/dialogs/settings/MemberTableRow.vue'
import MemberUpsellBanner from '@/platform/workspace/components/dialogs/settings/MemberUpsellBanner.vue'
import PendingInvitesList from '@/platform/workspace/components/dialogs/settings/PendingInvitesList.vue'
import WorkspaceMenuButton from '@/platform/workspace/components/dialogs/settings/WorkspaceMenuButton.vue'
import PendingInviteRow from '@/platform/workspace/components/dialogs/settings/PendingInviteRow.vue'
import { useMembersPanel } from '@/platform/workspace/composables/useMembersPanel'
import { cn } from '@comfyorg/tailwind-utils'
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
const {
searchQuery,
activeView,
sortField,
sortDirection,
maxSeats,
memberCount,
isOnTeamPlan,
hasLapsedTeamPlan,
hasMultipleMembers,
showSearch,
showViewTabs,
showInviteButton,
@@ -241,11 +259,10 @@ const {
filteredPendingInvites,
memberMenus,
isPersonalWorkspace,
members,
pendingInvites,
permissions,
uiConfig,
userPhotoUrl,
fetchBalance,
isCurrentUser,
isOriginalOwner,
toggleSort,
@@ -255,8 +272,40 @@ const {
} = useMembersPanel()
const { staticUrls } = useExternalLink()
const { t } = useI18n()
// Owners get "Need more members?" after the count, where the period reads as a
// separator; members see just the count, so drop the trailing period.
const membersUsageLabel = computed(() => {
const label = t('workspacePanel.members.membersUsage', {
count: memberCount.value,
max: maxSeats.value
})
return permissions.value.canInviteMembers ? label : label.replace(/\.$/, '')
})
const sortHeaderClass =
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
function sortIcon(field: string) {
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
return sortDirection.value === 'asc'
? 'icon-[lucide--chevron-up] size-3'
: 'icon-[lucide--chevron-down] size-3'
}
function ariaSort(field: string): 'ascending' | 'descending' | 'none' {
if (sortField.value !== field) return 'none'
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
}
function handleContactUs() {
window.open(staticUrls.discord, '_blank', 'noopener,noreferrer')
window.open(staticUrls.teamPlanRequests, '_blank', 'noopener,noreferrer')
}
onMounted(() => {
void fetchBalance().catch((error: unknown) => {
console.error('Failed to load workspace billing balance', error)
})
})
</script>

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