Compare commits

..

150 Commits

Author SHA1 Message Date
comfydesigner
f5a03b0ae0 feat: freeze auto-reload while subscription is paused
A paused (suspended) subscription can't spend, so auto-reload can't
fire — dim and disable the section the same way the lapsed state does,
rather than showing a misleading enabled/healthy tile. Extends the
frozen prop to cover paused alongside inactive.
2026-07-09 13:22:36 -07:00
comfydesigner
b1fc0e5c76 feat: hide invoice history table, defer to Stripe
Per-invoice download isn't feasible from our data, and Stripe already
owns the full invoice list + downloads, so the Invoices tab now just
shows the next charge and a Full invoice history link out. Drops the
local table, pagination, and its search box (kept useWorkspaceInvoices
for the next-invoice amount and easy restore).
2026-07-09 12:08:48 -07:00
comfydesigner
ea660e71ad fix: stack dense Credits-tab rows at a wider breakpoint
The credits/snapshot grid, plan header, and auto-reload only sit side
by side comfortably on the wide full-settings modal, not the narrower
standalone billing dialog, so raise their stack breakpoint from @2xl
(672px) to @4xl (896px). Lighter chrome (tabs, banners, footers) stays
at @2xl. Also keep the plan credit figure from breaking mid-value.
2026-07-09 01:34:12 -07:00
comfydesigner
45655af67b feat: responsive settings panels via container queries
Below a ~672px panel width (container @2xl), row layouts stack: the
tab bar drops its search to full width, the Credits plan header +
credits/snapshot grid + auto-reload go single-column, and the Activity
footer, invoice banner, and status banners stack their actions. Data
tables now nowrap all cells and scroll horizontally as a unit instead
of wrapping body text. Uses container queries (not viewport) so the
layout tracks the modal's actual width.
2026-07-09 01:27:20 -07:00
comfydesigner
c2d3d1dcb4 fix: stop table header labels wrapping to two lines
Header cells now nowrap, so a tight column lets the table scroll
horizontally (via the existing overflow-auto wrapper) instead of
wrapping the label. TableHead is used only by the workspace billing
tables, so this is scoped to those charts.
2026-07-09 01:05:47 -07:00
comfydesigner
0f15fa1972 feat: disable auto-reload in inactive subscription state
A lapsed plan can't auto-reload, so the section now reads Disabled
(toggle forced off) rather than just dimmed. AutoReloadSection gains a
frozen prop that owns the dim, blocks interaction, and forces the
off state, replacing the parent wrapper's dim so the tile no longer
double-dims.
2026-07-09 00:53:20 -07:00
comfydesigner
52e5f50b05 feat: redesign inactive team-subscription state
Per updated Figma (4453-24226): drop the $0/mo price line and the
'everything in Pro' value-prop panel. The credits tile now shows its
full breakdown frozen/dimmed via a new CreditsTile 'frozen' prop
(mirrors the paused treatment), and auto-reload stays visible but
dimmed instead of hidden. Header uses Manage payment + Reactivate.
2026-07-09 00:46:04 -07:00
comfydesigner
ab29ea72ed feat: plan-ending billing banner
A cancelled-but-still-active team plan now surfaces a calm, owner-only
notice: muted circle icon, 'Your team plan ends on {date}', and a
low-key secondary Reactivate plan action wired to useResubscribe. Sits
last in banner priority since it's an intentional, non-blocking state,
not a problem.
2026-07-08 21:39:32 -07:00
comfydesigner
b00c9f2bb9 fix: amber triangle for paused billing banner
A paused subscription is the escalation of a failed payment (credits
frozen, 'update payment to resume'), so it's an action-needed problem
like out-of-credits and payment-failed, not an informational notice.
All current banners now share the triangle-alert; the circle/muted
notice styling returns with the plan-ending banner.
2026-07-08 21:33:06 -07:00
comfydesigner
5b32c865ac feat: drop seat count from invite-limit tooltip
Matches the count-free member-limit dialog so no UI names a specific
seat number for enterprise or a changed cap.
2026-07-08 21:28:33 -07:00
comfydesigner
5bec5a06a8 fix: consistent severity icons on billing banners
Triangle-alert (amber) now marks every action-needed problem, so
payment-failed matches out-of-credits instead of showing the circle;
circle-alert (muted) is reserved for informational status notices like
paused. Collapses the icon and color into one severity condition.
2026-07-08 21:27:03 -07:00
comfydesigner
67d49de539 feat: drop hard seat count from member-limit dialog copy
The member-limit dialog no longer names a specific seat number, so it
needs no change for enterprise or if the cap moves, and it now notes
that pending invites count toward the limit (rescind an invite).
showMemberLimitDialog drops its now-unused maxSeats argument.
2026-07-08 21:17:54 -07:00
comfydesigner
141f2d933f feat: inactive team subscription state on Credits tab
Lapsed team/enterprise plans now show a reactivation header (title, 0
USD/mo, subtitle, Manage billing + Reactivate plan) and a plan
value-proposition panel in place of the live plan header and member
snapshot. Auto-reload is hidden while inactive. Reactivate wires to the
existing useResubscribe flow.
2026-07-08 21:15:30 -07:00
comfydesigner
c69d36a130 revert: drop the usage bar from the account popover
Removes the monthly-usage progress bar under the credit balance in the user
popover — not needed. The shared usage math stays in useSubscriptionCredits
(still consumed by the credits tile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:32:22 -07:00
comfydesigner
2d6aea2d8f fix: stop auto-page-size from overflowing by a partial row
Measure row/header heights with getBoundingClientRect (fractional) instead
of offsetHeight (integer). Truncated integer heights made the fit calc place
one row too many, overflowing the scroll container by a few pixels and
showing a scrollbar. Fractional heights keep a not-quite-fitting row out,
leaving at most ~1 row of empty space.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:24:26 -07:00
comfydesigner
fc3bb1311a feat: show the credits usage bar in the user popover
Adds the monthly-usage progress bar under the credit balance in the account
popover (paid, active plans only), matching the design. Centralizes the
allowance-total and usage math in useSubscriptionCredits so the popover and
the credits tile share one source instead of duplicating it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:19:31 -07:00
comfydesigner
c5b92e1419 fix: keep the credits footer floated to the bottom
Restore mt-auto so the footer floats to the panel's bottom edge; the earlier
pb-10 -> pb-6 change is what aligns it with the other tabs' footers, not
dropping the float.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:11:28 -07:00
comfydesigner
36787eb6c3 style: rebalance activity table user/event-type widths
Pin the User column to a fixed width and let Event type flex to absorb the
table's slack, instead of User (with its short names) soaking up all the
empty space.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:09:57 -07:00
comfydesigner
4e45d69686 style: drop last-row divider and tighten credits footer gap
- Remove the trailing divider on the last table row across all five tables
  so it doesn't double up against the container edge.
- Drop mt-auto from the credits-tab footer so it follows the content with the
  normal gap instead of floating to the panel's bottom edge, and match the
  bottom padding to the other tabs (pb-6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:06:24 -07:00
comfydesigner
ae4075b46c style: soften dividers to /20 and drop hover on read-only tables
Row dividers go from /20... down from /30 to /20. Also removes the row hover
highlight on the read-only tables (Activity, Invoices, Members, overview
snapshot) since those rows aren't interactive; the Allowlist keeps its hover
because its rows are selectable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:00:50 -07:00
comfydesigner
c1fc014d34 style: soften table row dividers
Drop the row divider opacity from /60 to /30 so the lines read as subtle
separators; the header/label divider stays at /60.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:54:15 -07:00
comfydesigner
d902343c91 refactor: use divider rows across the workspace tables
Replaces the alternating zebra backgrounds (and the plain / hover-pill
variants) with shadcn-style divider lines plus a subtle hover highlight, so
Activity, Invoices, Members, Allowlist, and the overview snapshot all share
one consistent row treatment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:51:55 -07:00
comfydesigner
b66ad926ab fix: left-align activity hover cards to their trigger
Anchor the user and partner-node hover cards to the start of their trigger
(align="start") so the popover's left edge lines up with the row content,
instead of the default centered placement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:33:10 -07:00
comfydesigner
04d371c581 feat: emphasize add-credits when the tile is fully out of credits
Restores the inverted (emphasized) add-credits button when monthly credits
are depleted and no additional credits remain; spending-additional keeps the
quieter tertiary button, and paused stays tertiary + disabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:28:37 -07:00
comfydesigner
72800913e1 chore: rename billing-mock past_due state to at_risk
"past_due" is Stripe jargon that reads oddly next to the other subscription
statuses; "at_risk" better conveys "payment failing, about to pause."

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:23:02 -07:00
comfydesigner
d558296f3d feat: dim the credits tile while the subscription is paused
Paused subscriptions can't spend credits, so fade the whole tile to read as
frozen and defer to the Update-payment banner — matching how the auto-reload
tile dims when paused. Only applies to the paused state, not the at-risk
grace period.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:22:41 -07:00
comfydesigner
e12dea7af1 feat: add empty state to the recent activity snapshot tab
The snapshot tile now shows a per-tab empty state: the existing
"No credits used yet this month" for Top spenders, and a new
"No activity yet" for Recent activity when there are no events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 18:16:20 -07:00
comfydesigner
698c74b458 refactor: use lucide-coins for the credits icon and refresh banner copy
Swaps every credits icon from lucide-component to lucide-coins across the
credit displays (top-up dialogs, user popovers, pricing table, credit badge,
node search, partner node cost). Also updates the out-of-credits banner copy
to the softer "add more credits to continue generating or wait until credits
refill" wording.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:52:06 -07:00
comfydesigner
1f48c6979e fix: remove duplicate out-of-credits banner on the members tab
The members tab had its own MembersOutOfCreditsBanner, which now duplicates
the out-of-credits variant added to the shared BillingStatusBanner. Drop the
members-specific banner, its useMembersPanel logic, the orphaned component,
its i18n keys, and the tests that covered it — the shared banner now handles
this state consistently across all tabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:49:16 -07:00
comfydesigner
a52cb209fe fix: add bottom padding to members and allowlist tabs
These panels run their own layout (not through PlanCreditsPanelContent), so
they missed the tab bottom padding and sat flush to the dialog edge. Add
pb-6 to match the Activity/Invoices tabs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:37:48 -07:00
comfydesigner
f2f3e3652d feat: move out-of-credits messaging to the status banner
Adds an out-of-credits variant to the shared billing status banner (owner/
admin only, dismissible), slotting into the same top banner in priority
order paused → payment-failed → out of credits. In exchange the credits
tile drops its punch-out empty-state notices, so its depleted/out-of-credits
states read lean like the paused state (no inset toast, tertiary add-credits
button). Prunes the now-unused subscription notice i18n keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 17:24:53 -07:00
comfydesigner
eac08385a1 fix: lower table row floor to 1 and trim tab bottom padding
- Activity/Invoices tables fit rows down to 1 instead of forcing a floor of
  5, so short screens paginate instead of overflowing with a scrollbar.
- Reduce the Activity/Invoices bottom padding from 40px to 24px to match the
  top and reclaim vertical space.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:33:33 -07:00
comfydesigner
562886d1dc feat: add enterprise tier to the billing mock and plan name
Adds an "enterprise" option to the billing mock harness tier picker
(subscription_tier ENTERPRISE) and relabels the team plan as "Enterprise"
wherever the team plan name shows — the Credits-tab plan header and the
cloud subscription panel. Layout, credits, and seats are unchanged; the
team credit stop still drives the credits bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:31:15 -07:00
comfydesigner
c35c6f2871 feat: simplify paused credits tab per design
- Keep the percentage on the allowance row when paused (e.g. "0% used")
  instead of swapping in "Refill paused"; drop the now-unused key.
- Replace the plan header's "Renews on {date}" with "Paused" when the
  subscription is paused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:20:37 -07:00
comfydesigner
c1f393de2e feat: surface full invoice history in the paused billing banner
Instead of dropping the "Full invoice history" action when the next-invoice
banner is hidden on pause, add it as a ghost button beside "Update payment"
in the billing status banner (via a new actions slot), shown only on the
paused Invoices tab. Reverts the footer fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:16:35 -07:00
comfydesigner
678f9e06e4 feat: rename first tab to Credits and gate next-invoice on paused
- Rename the Plan & Credits first tab "Overview" -> "Credits".
- Drop the "Next month invoice" tile from that tab; it now lives only on
  the Invoices tab, removing the duplication.
- On the Invoices tab, hide the next-invoice banner when the subscription
  is paused (the parent "Subscription paused" banner covers that state) and
  fall back to a footer "Full invoice history" link so it stays reachable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 16:11:28 -07:00
comfydesigner
57aca543b2 feat: add next-month-invoice banner to the invoices tab
Mirrors the overview banner at the top of the Invoices tab per design:
muted "Next month invoice" label, 24px semibold amount with a 16px USD
suffix, and the "Full invoice history" action moved from the footer into
the banner as a secondary button. Footer keeps only pagination.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:48:04 -07:00
comfydesigner
7b808f2674 fix: give the paused auto-reload badge the inverted treatment
Paused is an alert state; use the high-contrast (inverted) badge so it
stands out, keeping the quieter secondary badge for the "off" state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:43:40 -07:00
comfydesigner
92c0f8f4c9 fix: match auto-reload amount size to the credits tile total
The reload credits number now uses the same 24px treatment as the total
credits number, per design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:42:09 -07:00
comfydesigner
878b540b40 feat: track full-year allowance on the credits bar for annual plans
Annual subscribers receive the whole year's credits upfront, so the bar's
total is now the monthly nominal times the cycle length (x12 for annual) and
the "% used" math follows. Renames the monthly-prefixed internals to
allowance/cycle and makes the progress and "used after" copy cycle-neutral.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:41:03 -07:00
comfydesigner
b9ad439544 feat: update auto-reload budget tile to match design
Budget section now shows a color-coded "{percent}% spent" on the right of
the Monthly budget row (muted → amber near full → red when paused/full) and
a single "{spent} of {budget}" sub-label under the bar, with a divider above
it. Drops the left spent label and the reloads-left line, and prunes the
now-unused tile i18n keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:34:34 -07:00
comfydesigner
c26d7e0063 feat: fit snapshot rows to the credits tile height
Both overview tiles stretch to equal height via the grid; the credits tile
sets that height and the top-spenders tile now fits as many user rows as the
space allows (measured with useAutoPageSize), keeping "See more" pinned to
the bottom. Raises the snapshot pool so a taller tile can show more rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:29:35 -07:00
comfydesigner
4bfce5fac3 fix: mute allowance label and vary it by billing duration
The credits-bar cycle label is now muted like the surrounding labels and
reads "Yearly" on annual subscriptions, "Monthly" otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:24:14 -07:00
comfydesigner
10f8271b8e fix: swap credit tile font sizes to match design
Plan header monthly grant is the 16px line; the Total credits balance is
the prominent 24px number. Had these inverted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:22:07 -07:00
comfydesigner
0c2fa9afc4 feat: simplify credits + auto-reload tiles per updated design
- Plan header shows monthly credit grant (coins) instead of $/mo price;
  next-month invoice already covers the dollar amount.
- CreditsTile: total balance uses 16px text; monthly row shows a single
  "{percent}% used" status (or "Refill paused") in place of the
  "used / left of total" breakdown and per-row refills date.
- AutoReloadSection: reload amount uses 16px text; drop the recent-reload
  history block and its now-unused helpers.
- Prune orphaned subscription i18n keys (refillsDate, refillsNextCycle,
  creditsUsed, creditsLeftOfTotal); add percentUsed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:13:53 -07:00
comfydesigner
33233a3aed chore(workspace): rename Activity inflow event to "Additional credits added"
Was "Credit top-up".
2026-07-08 13:55:25 -07:00
comfydesigner
a9eee9e14c feat(workspace): make Activity a credit ledger (inflows + usage)
Add auto-reload / top-up inflow events to the activity feed: they render '—' in
the User column (workspace-level, not a person), '—' for details, and a gold
'+N' credit amount to distinguish adds from usage. Rename the column "Credits
used" -> "Credits" since it now shows both directions. Inflows are excluded from
per-user rollups and stay workspace-level even in a member's scoped view.
2026-07-08 13:39:49 -07:00
comfydesigner
44d698678a test: align CreditsTile + MembersPanelContent tests with billing changes
MembersPanelContent now reads useBillingContext (isPaused) and renders
BillingStatusBanner — mock the context and stub the banner. CreditsTile's mock
gains isPaused, and its stale add-credits variant expectation is updated to
'tertiary' to match the component.
2026-07-08 13:16:24 -07:00
comfydesigner
cb7ef058a4 chore(workspace): expose aria-sort on sortable table headers
Add aria-sort (ascending/descending/none) to the Activity, Invoices, Members,
and Partner-nodes column headers so screen readers announce the active sort
column and direction. Adds a small ariaSort() helper alongside each sortIcon().
2026-07-08 13:08:39 -07:00
comfydesigner
fae8e049df chore(workspace): associate auto-reload dialog labels with their inputs
Add for/id on the threshold and reload fields and aria-labelledby on the budget
field so screen readers announce each input's purpose.
2026-07-08 12:42:01 -07:00
comfydesigner
53ad3d681d chore: address CodeRabbit nits — locale-aware formatting, a11y, api timeout
- Route currency/date through vue-i18n n()/d() instead of hardcoded en-US
  (invoices, overview, auto-reload dialog + section, overview renewal date)
- a11y: scope="col" on table headers, real alt on workspace avatar, role=status
  on the billing banner
- partnerNodesApi: 10s request timeout so a hung call surfaces the error toast
- overview: lucide ellipsis instead of PrimeIcons, locale-aware Learn more link
2026-07-08 12:40:16 -07:00
comfydesigner
c90babcf73 fix: address CodeRabbit review — correctness fixes + isPaused dedup
- AutoReloadDialogContent: ?? instead of || for numeric defaults (0 is valid)
- useAutoReload: budgetUsedFraction null-check consistent with hasBudget (0 budget)
- SettingDialog: flush content-padding for all workspace panels, not just Plan&Credits
- WorkspaceMembersPanelContent: void the onMounted fetches (no floating promises)
- usePartnerNodes: restore original last_modified on failed enable/bulk rollback
- lift isPaused into useBillingContext; drop the duplicated derivations in
  CreditsTile / MembersPanelContent / BillingStatusBanner
2026-07-08 11:55:16 -07:00
comfydesigner
ce73c2480f feat(workspace): rename Partner Nodes tab to Allowlist with shield-check icon
The section will host both partner-node and model allowlists (as sub-tabs) later,
so rename the sidebar entry to the umbrella "Allowlist" and swap the node-specific
icon for shield-check. Internal keys/routes unchanged.
2026-07-07 19:39:10 -07:00
comfydesigner
3cb63e3209 feat(workspace): hover card showing the partner node used on activity rows
Add a HoverCard on the 'Partner node usage' event-type cell in the Activity tab
that reveals which partner node was used ("Partner node used -> Nano Banana
Pro"), mirroring the existing per-user hover card. Mock the node per event.
2026-07-07 19:14:36 -07:00
comfydesigner
7317b73a38 style(workspace): pull invoices Event type next to Date
Fix the Date column width (w-40, matching Activity) so Event type no longer
splits the leftover space with it and sits right beside Date, per the Figma.
2026-07-07 19:10:47 -07:00
comfydesigner
beb0a5e12b fix(workspace): overlay partner-nodes selection bar to stop panel reflow
The SelectionBar's in-flow wrapper was a child of the gap-4 flex column, so
toggling it in added a gap and nudged the bottom rows. Anchor the panel and
overlay the bar (absolute) so it floats without participating in layout.
2026-07-07 19:08:44 -07:00
comfydesigner
0124ea8d43 refactor: extract reusable SelectionBar, use it for partner nodes
The floating bulk-selection bar built for Media Assets (PR #13043) is the same
shell the partner-nodes tab needs — only the action differs. Extract the shell
(inverted floating pill: deselect, count, right-aligned actions slot) into a
shared SelectionBar; MediaAssetSelectionBar now composes it (download/delete in
the slot), and the partner-nodes tab uses it with a bulk enable/disable Switch.
2026-07-07 18:29:33 -07:00
comfydesigner
0be144d1d6 feat(subscription): distinct paused state for the credit tile
When the subscription is paused (failed payment), the zeroed balance was falling
into the generic out-of-credits UI. Give paused its own state: drop the
out-of-credits notice, show "Refill paused" + an empty bar (0 used), and disable
Add credits — since topping up doesn't help until payment is resolved. Out of
credits stays independent of the paused/payment-failure state.
2026-07-07 17:43:24 -07:00
comfydesigner
77330e8f60 feat(workspace): always show partner-nodes select-all checkbox
Keep the header select-all visible instead of only after a selection exists, so
bulk-selection is discoverable up front (Gmail/Linear pattern). Row checkboxes
stay hover-revealed.
2026-07-07 17:34:11 -07:00
comfydesigner
8fb73ff290 fix(workspace): place billing banner below panel headers, dedupe when paused
Move the billing banner below the header/tab row in Members and Partner Nodes so
it sits in the same spot as Plan & Credits (under the tabs) instead of above.
When the subscription is paused the balance is zero, which also tripped the
Members out-of-credits banner — suppress it while paused so only the paused
banner shows.
2026-07-07 17:32:50 -07:00
comfydesigner
674143721f fix(workspace): dark billing banner with inverted button
Keep the banner on the dark surface; use the inverted (white) button variant so
the Update payment action is what catches the eye, per the Figma.
2026-07-07 17:28:11 -07:00
comfydesigner
52c7944f2c style(workspace): invert billing banner, clarify harness field labels
Use the inverted (light-on-dark) treatment for the payment banner so it draws
the eye against the dark panel. Rename harness picker labels 'state' -> 'subscription
state' and 'balance' -> 'credit balance' (labels are display-only, decoupled from
the config keys).
2026-07-07 17:13:12 -07:00
comfydesigner
6dcc34b595 feat(workspace): payment-failed / paused billing banner across workspace tabs
Add a BillingStatusBanner that surfaces a payment failure on every workspace
settings tab (Plan & Credits sub-tabs, Members, Partner Nodes), per the Figma
annotation. Two states: payment declined (grace-period warning, owner/admin only
since members can't act) and subscription paused (owners/admins get an Update
payment action; members get an informational variant pointing to admins). Reads
the existing billing_status / subscription_status the context already exposes.

Add 'paused' to BillingSubscriptionStatus and two harness states (past_due,
paused) to drive it; paused also zeroes the balance.
2026-07-07 16:57:54 -07:00
comfydesigner
bef5616aba fix(workspace): clamp billing-mock panel into viewport on load
The panel persists its dragged left/top, but on load it applied them raw, so a
position saved on a larger display landed off-screen on a smaller one with no
header to grab. Re-clamp the persisted position to the current viewport.
2026-07-07 16:38:29 -07:00
comfydesigner
7b7cd0342f style(workspace): widen overview footer link gap to 16px
gap-2 -> gap-4 per updated spacing.
2026-07-07 16:36:22 -07:00
comfydesigner
8d8d0ed61d fix(workspace): reserve partner-nodes scrollbar gutter, dedupe node names
Swap the hidden scrollbar for a reserved gutter (scrollbar-gutter: stable) so the
list keeps a visible scrollbar without the rows shifting when it appears; mirror
the gutter on the auto-enable row so its toggle stays aligned with the column.

Dedupe partner nodes by display name (5 collisions, e.g. two "OpenAI GPT Image
2") so the governance table has no indistinguishable duplicate rows: 213 -> 208.
2026-07-07 16:24:35 -07:00
comfydesigner
d7e074bbf5 fix(workspace): float overview footer to bottom, hide partner-nodes scrollbar
The overview footer links now use mt-auto so they pin to the bottom of the panel
when the content is short (a member's near-empty tab) and scroll below the
content when it overflows — a pure-flexbox sticky footer, no height math.

The partner-nodes table is the only settings table that isn't paginated, so its
scrollbar was the lone visible one in the dialog and its width shifted the row
toggles out of line with the auto-enable toggle. Hide it (scrollbar-hide, as the
overview already does) so the columns line up again.
2026-07-07 16:19:33 -07:00
comfydesigner
4daf17c9d7 fix(workspace): list the full partner-node catalog in the governance mock
The canned list only had ~96 nodes — roughly the first page of the API-node
catalog alphabetically — so everything after "Luma" (OpenAI, Runway, Recraft,
Tripo, Veo, Stability AI, Meshy, Rodin, Vidu, Wan, etc.) was missing. Replace it
with the full 213-node set, matching what the node-search "Partner" filter shows,
labelled by the node's partner category.
2026-07-07 16:11:49 -07:00
comfydesigner
ab275ba35e fix(workspace): even partner-node row fill + reactive auto-enable label
The disabled-node opacity was on the whole TableCell, dimming the selection/hover
fill on the name and partner cells while the other cells stayed full — an uneven
row fill. Dim only the cell content so the fill is uniform.

Make the auto-enable footer label reflect the switch (auto-enabled/auto-disabled)
and stack both strings in one grid cell so the width is fixed and the row doesn't
reflow when it toggles.
2026-07-07 15:57:45 -07:00
comfydesigner
f5f365c58a fix(workspace): left-align overview footer links
Match Figma: cluster the footer links to the left with an 8px gap instead of
spreading them across the row.
2026-07-07 15:52:34 -07:00
comfydesigner
64bcf2748e Merge remote-tracking branch 'origin/main' into comfydesigner/team-workspaces-v1
# Conflicts:
#	src/platform/cloud/subscription/components/CreditsTile.vue
2026-07-07 15:15:13 -07:00
comfydesigner
b2400f764d fix(workspace): drop trailing period from members usage label
Members (who can't invite) see just the count, so the trailing period reads as a
stray dangling mark; owners keep it as a separator before "Need more members?".
2026-07-07 15:06:53 -07:00
comfydesigner
4925f0ca0c style(workspace): space out overview footer links
Use justify-between so the footer links spread across the row.
2026-07-07 15:06:51 -07:00
comfydesigner
123bdad84c fix(workspace): drop unused AutoReloadScenario export
knip flags it as an unused exported type (the dev harness keeps its own local
copy of the union), which fails the pre-push hook. Scope it to the module.
2026-07-07 15:06:50 -07:00
comfydesigner
04e79dc2da feat(workspace): role-based visibility for members
- Members can view pending invites (view-only; no resend/revoke) and see just
  the Email/Role columns in the members table
- Hide billing from members: plan price, Manage payment, member snapshot, next
  invoice, the auto-reload section, and the Invoices tab
- Scope the Activity tab to the member's own usage and right-align its
  pagination; hide the per-user footer links
- Gate the members footer "Contact us" / request-more behind invite permission
2026-07-07 15:06:48 -07:00
comfydesigner
9b54efdef4 feat(workspace): raise seat limit to 50 and link request-more to the team-plan form
- MAX_WORKSPACE_MEMBERS 30 -> 50
- Member-limit dialog's "Request more" opens the team-plan request form
2026-07-07 15:06:46 -07:00
comfydesigner
cf3e4c4620 feat(workspace): redesign the auto-reload dialog
- Drop the subtitle; render Monthly budget as a divider band, not a boxed card
- Refine copy and collapse the minimum-amount error to a single string
- Size the Credits/USD toggle to match the Update button
2026-07-07 15:06:44 -07:00
comfydesigner
53c7566665 style(workspace): flush settings panels and tabular credit figures
- Add a 'flush' content-padding variant to BaseModalLayout; the settings
  dialog uses it for workspace panels so content runs to the bottom edge
- Tabular-nums on the CreditsTile figures so columns align
2026-07-07 15:06:43 -07:00
comfydesigner
001b9f3ace feat(workspace): auto-reload credits, table auto-sizing, Overview polish
- Credit auto-reload: section, tile states (healthy/near-limit/paused/off/
  empty), and configure dialog with Credits/USD toggle and $5 minimum
- Auto-size Activity/Invoices tables to the dialog height (useAutoPageSize)
- Activity footer: Full activity link + See Members deep-link
- Overview footer links wired (Learn more, Partner Nodes pricing, support)
- Overview snapshot empty state; future renewal date
- billingmock: balance "full" state, team_credit_stop scaling, auto-reload
  scenario picker (live, no reload) + stale-value sanitizing on load
- Plan & Credits nav icon -> receipt-text
2026-07-07 15:06:41 -07:00
comfydesigner
2324417e22 chore(billingmock): tie member usage to balance state, add partial
Zero every member's credits_used_this_month in the fully-funded state so
both Overview tiles agree that nothing has been spent, and add a 'partial'
balance option (now the default) where members reflect their mock usage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:39 -07:00
comfydesigner
9bbe75eeaf feat: snapshot uses Members data; See more deep-links per tab
Derive the Overview member snapshot from the workspace members store (Top
spenders by credits, Recent activity by last activity) so it matches the
Members tab. Wire See more: Top spenders opens the Members panel
pre-sorted by credit usage (via a settings-navigation helper + a one-shot
members sort), Recent activity opens the Activity tab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:37 -07:00
comfydesigner
0ac06403d8 style: strip native chrome from the additional-credits info button
Reset the raw info tooltip button's native appearance so it renders as a
plain muted icon (with a cursor-help + hover tint) instead of a filled
button box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:36 -07:00
comfydesigner
786ae99afa style: unify disabled styling for workspace menu items
Drop the redundant per-item text-danger/50 and opacity-50 classes so
disabled Delete and Leave both render with the shared data-disabled muted
style; Delete stays red only when actually enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:34 -07:00
comfydesigner
1e2cfd54c8 style: restore 24x20 tile padding; tertiary Add credits button
Revert both Overview tiles to px-6 py-5 padding and switch the CreditsTile
Add credits button to the tertiary variant so it matches the See more
button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:33 -07:00
comfydesigner
b206ca457f style: match Overview credits tile padding to the snapshot tile
Pass p-4 to the Overview CreditsTile so its padding matches the member
snapshot tile (legacy CreditsTile usages keep px-6 py-5).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:31 -07:00
comfydesigner
5dd04a5e3f refactor: mini snapshot table reuses shadcn Table; fix line tabs; tertiary See more
Rebuild the member-snapshot list with the shared Table component (zebra
rows, coins header, divider) so it mirrors the Activity/Invoices tables
in miniature. Reset the native button appearance on TabsTrigger and drop
the list border so the tabs render as clean underline tabs. Use the
tertiary button variant for See more so it stands out on the tile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:29 -07:00
comfydesigner
5e5bf8891a refactor: reuse CreditsTile in Overview; add shadcn line Tabs
Replace the hand-built Overview credits tile with the shared CreditsTile
(real billing data), swapping its credit icon to lucide--coins and
letting callers pass a class so the Overview can drop the border. Add a
shadcn-style line Tabs component and use it for the member-snapshot
Top spenders / Recent activity toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:27 -07:00
comfydesigner
e75f32b56f chore(billingmock): add admin role, map owner to current user, keep dialog open
Add an 'admin' role option (owner-role, non-creator) to the harness. For
'owner', assign the signed-in email to the creator member so the
original-owner gating (Change plan / Cancel plan) resolves true; 'admin'
keeps a separate creator and injects the current user as a non-original
owner. Stop pointerdown/focusin from bubbling out of the panel so
interacting with the picker no longer dismisses the settings dialog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:26 -07:00
comfydesigner
46a357113b feat: owner-only Cancel plan menu on the Overview plan header
Add a Cancel plan overflow menu (owner + active, non-cancelled, non-free
subscription) to the Overview plan header, matching the old design. Hide
the overflow menu and Change plan for non-owners (Admins/Members).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:24 -07:00
comfydesigner
d5ebe903bd style: use modal panel background for Overview tiles, drop borders
Match the credits and member-snapshot tiles to the modal sidebar color
(bg-modal-panel-background) and remove their outline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:22 -07:00
comfydesigner
c3c22d3e3a style: round zebra rows 4px and remove row hover on Activity/Invoices
Paint the alternating stripe on the cells (with 4px rounded end caps)
instead of the table row, so it can render a radius and no longer flickers
on hover. These read-only tables have no per-row interaction, so the
hover state is fully removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:21 -07:00
comfydesigner
93dc76728e style: make Overview footer 32px tall
Match the Overview footer-link row height to the other footers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:19 -07:00
comfydesigner
a0932ba40f fix: hide legacy Credits nav when workspace Plan & Credits is shown
The per-account Credits panel duplicated the new workspace Plan & Credits
entry in the Workspace sidebar group; suppress it when the workspace
panel is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:18 -07:00
comfydesigner
af5e9d0766 feat: build Plan & Credits Overview (plan, credits, member snapshot)
Replace the Overview placeholder with a real layout: plan header
(price/renewal + actions), a credits tile (total/monthly/additional with
progress) beside a member-snapshot tile (Top spenders / Recent activity
+ See more), and a next-invoice card with footer links. Data is
client-side mock via useWorkspaceOverview; See more / Invoices navigate
to those tabs. Adds a small ProgressBar. Auto-reload section follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:16 -07:00
comfydesigner
c3ca88161c feat: tabular date column, subtle zebra rows, 11 rows per page
Use tabular-nums on the Activity/Invoices date column, add a faint
alternating-row tint (matching the Figma) on those read-only tables, and
fit one more row per page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:14 -07:00
comfydesigner
e499bfc172 fix: prevent clipped table rows, tidy pagination and popover
Make the Activity/Invoices table area a graceful scroll region with a
sticky header (so a page can never clip its last row) and drop the page
size to 10 to fit the card. Render pagination with the shared Button
component, and use the semantic border token on the hover card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:12 -07:00
comfydesigner
96ac9f04c0 fix: lift hover card above the settings dialog; add muted user badge colors
Share the hover card's open state so its content can lift past the
settings dialog's incrementing modal z-index (it was rendering behind
the dialog, so nothing appeared on hover).

Give user monogram badges a stable, muted low-saturation color from a
sampled palette across the Members, Pending, and Activity tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:10 -07:00
comfydesigner
57f24854be style: 10px activity badge letters and larger page size
Shrink the Activity user badge letter to 10px (text-2xs), and bump
Activity/Invoices to 12 rows per page (with a longer mock list) now that
rows are ~40px tall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:08 -07:00
comfydesigner
3f9f2a7782 feat: add user hover card to the Activity table
Add a shadcn-style HoverCard (HoverCard/Trigger/Content on reka-ui) and
use it on the Activity table's User cell to surface that user's lifetime
credits used and last activity, aggregated across all events.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:07 -07:00
comfydesigner
f5844a0a87 style: shrink activity user badges to 20x20
Use a 20px avatar in the Activity table (Members keeps 32px).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:05 -07:00
comfydesigner
f01448d6b9 fix: use static i18n keys for Plan & Credits tab labels
Resolve tab labels via static t() calls so the unused-key linter can
trace them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:03 -07:00
comfydesigner
3a31a99eaf feat: add Activity and Invoices views to Plan & Credits
Add Overview/Activity/Invoices sub-tabs to the Plan & Credits panel.
Activity and Invoices render shadcn tables (Date/User/Event/Credits and
Date/Event/Price) sourced from client-side mock data, sortable, with a
32px footer that pairs a history link with a reka-based Pagination
component instead of scrolling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:02 -07:00
comfydesigner
053d559d15 style: tighten out-of-credits banner to match Figma
Reset the body paragraph's default margin so the banner loses the extra
vertical whitespace and matches the compact Figma layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:06:00 -07:00
comfydesigner
8135842cce chore: expand mock roster so the members table scrolls
Grow the team workspace to a full roster (near the seat cap) so the
sticky-header scroll behavior and usage footer can be seen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:58 -07:00
comfydesigner
6ecb13a6ce chore: add more mock members to the team workspace
Expand the billing mock harness team roster to eight members with varied
roles, activity times, and monthly credit usage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:57 -07:00
comfydesigner
de95ef0fbd fix: make the Rename menu item start inline editing
Selecting Rename opened the editor, but reka returned focus to the menu
trigger on close, blurring the just-focused input and committing an empty
rename. Suppress that focus restoration for the close that starts a
rename so the input keeps focus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:55 -07:00
comfydesigner
f03cd5de33 style: match members footer height and Contact us button sizing
Fix the members footer to 32px tall (matching the partner nodes footer)
and bump Contact us to size md with 14px text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:53 -07:00
comfydesigner
529248fb7e style: show only the email on pending invite rows
Drop the derived display-name line; the email is the sole identifier
for a pending invite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:52 -07:00
comfydesigner
2c9bee1da3 style: letter avatars for members, drop row hover, round partner rows
Use letter-initial avatars in the Members table (matching pending
invites) and drop the unused photo/UserAvatar path. Remove the hover
highlight on member and pending-invite rows. Give partner-node rows a
4px radius by painting the hover/selected background on the cells with
rounded end caps (table rows can't render border-radius directly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:50 -07:00
comfydesigner
ba494b3d4a fix: match sort-header font to page font in workspace tables
Native button headers didn't inherit the page font-family, so sortable
column labels rendered in a different font than the non-sortable Pending
Email span. Force font inheritance on the shared sort-header class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:49 -07:00
comfydesigner
2b49d78637 fix: give invite dialog a fixed width and lighter email badges
The dialog hugged its content width, so it grew while typing. Pin it to
a fixed 33rem so emails wrap to new lines (height grows) instead. Raise
the email badge to tertiary-background-hover so it reads against the
input background.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:47 -07:00
comfydesigner
b579234d55 feat: mock pending invites and hide empty Pending count
Serve two mock pending invites (team workspaces) from the billing mock
harness, and show the Pending tab as plain 'Pending' with no count when
there are none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:45 -07:00
comfydesigner
d9a5d28364 style: drop redundant Partner nodes header title
Remove the 'Partner nodes' title so the description moves up and the
table aligns vertically with the Members tab. Center the header row to
match the Members header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:43 -07:00
comfydesigner
5ec65f366e style: use lg size for Members/Pending tab buttons
Match the tab buttons to the Invite button's size and styling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:42 -07:00
comfydesigner
5e9d1b89f9 feat: add member-limit and workflow-queued dialogs
Add a shared 'request more' dialog (title, message, Close + Request more)
and register both the 'workspace at member limit' and 'workflow queued'
variants in the dialog service. The invite button now stays enabled at
the seat cap and opens the member-limit dialog on click; the queued dialog
is previewable from the billing mock harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:40 -07:00
comfydesigner
4308498b79 feat: redesign Members panel as a table with activity and credits
Convert the active-members and pending-invites lists to the shadcn table
(matching Partner Nodes): columns Email, Role, Last activity, Credits
used this month, and a row overflow menu, with sortable headers. Move the
Members/Pending tabs up into the header row beside search and invite, and
add a per-seat usage footer.

Add an out-of-credits banner above the table, driven by the workspace
balance, with Dismiss and Add credits actions. Menus render non-modal so
they don't dismiss the settings dialog. Retire MemberListItem and
PendingInvitesList in favor of MemberTableRow and PendingInviteRow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:38 -07:00
comfydesigner
3bd7681e84 feat: add member last-activity and monthly-credit fields
Extend WorkspaceMember (and the optional API fields) with lastActivity
and creditsUsedThisMonth for the redesigned Members table, populate them
in the billing mock harness, and add an abbreviated relative-time helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:37 -07:00
comfydesigner
1e44a207b9 feat: add Rename to workspace menu and fix dialog dismissal
Add a 'Rename Workspace' item (above a divider) to the header overflow
menu, wired through a shared useWorkspaceRename composable so it and the
header double-click drive the same inline edit. Drop the menu-item icons
to match the Figma.

Fix the settings dialog closing when the menu trigger is clicked while
open: the dialog is non-modal but reka's DropdownMenu defaulted to modal,
which disables outside pointer-events and retargets the trigger click to
a non-whitelisted body element. Thread a modal prop through DropdownMenu
and render this menu non-modal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:35 -07:00
comfydesigner
ba99a98807 fix: match rename input font and pluralize char-limit hint
Force the inline rename input to inherit the page font-family so it
renders identically to the resting name heading (native controls fall
back to the UA font otherwise). Reword the character-limit hint to
'N characters left' with proper singular/plural forms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:33 -07:00
comfydesigner
98e42e53b7 feat(workspace): reduce workspace name limit to 30 characters
Extract a shared WORKSPACE_NAME_MAX_LENGTH constant (was 50, now 30) and use it
across the inline rename, create, and edit flows plus the validation copy, so
the limit stays consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:31 -07:00
comfydesigner
baf75706bd style(workspace): default cursor on the workspace name until editing
The resting name used a text cursor, implying it was already editable. Use the
default cursor; the input's native text cursor is the feedback once editing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:30 -07:00
comfydesigner
4242be2707 feat(workspace): rename shows a '{n} left' counter near the 50-char limit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:27 -07:00
comfydesigner
5c7ccbf2a4 style(workspace): auto-enable footer to 32px, drop top padding
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:26 -07:00
comfydesigner
21923f4e9a feat(workspace): move delete/leave to a top overflow menu; rename-input polish
Remove the members-tab overflow (...) button — editing is now inline — and
relocate delete/leave to a WorkspaceMenuButton in the dialog header next to the
X (shown on every workspace panel). Drop the now-inline 'Edit workspace details'
item and hide the menu when it has no actions. Rename input: strip native
appearance/border/padding so the text no longer shifts on edit; enforce a
50-char limit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:24 -07:00
comfydesigner
6e67964317 feat(workspace): mock workspace image replace via hover pencil (owner/admin)
Add optional imageUrl to WorkspaceProfilePic (renders the image over the
gradient+initial fallback). In the settings header, hovering the avatar reveals
a pencil overlay (owner/admin only); clicking opens a file picker and shows the
chosen image as a local data-URL preview. Client-side only — no upload or
persistence, per the prototype scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:22 -07:00
comfydesigner
11be2af866 feat(workspace): inline rename in the settings header (owner/admin)
Double-click the workspace name to rename it inline (Enter/blur commits, Esc
cancels), gated to owners/admins with a 'Double-click to rename' tooltip; uses
the existing updateWorkspaceName. Make the mock harness route handlers
body-aware and add a PATCH /api/workspaces/:id route so the rename reflects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:21 -07:00
comfydesigner
2c92b2cc6c style(workspace): members-header overflow button uses secondary-background
Drop the lighter selected-surface override so the members-header ... button
matches the search, invite, and close buttons (all secondary-background).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:19 -07:00
comfydesigner
ffb925837a style(ui): bump lg SearchInput text to 14px
The large SearchInput variant (settings content search, asset browser, sample
model selector) now uses text-sm (14px) instead of text-xs; the container
already has room. Other sizes unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:17 -07:00
comfydesigner
af2f09304d feat(workspace): drop partner filter for V1, tabular-nums on selection count
Search already matches partner names, so the separate partner filter is
redundant for V1 — remove it (and the now-unused composable state, i18n, dead
imports); revisit if requested. Use tabular-nums on the bulk-selection count so
the toolbar width barely moves as the number changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:16 -07:00
comfydesigner
b01c01625a fix(workspace): stop breaking full-color provider icons; taller header; unbold titles
The comfy icon set keeps multi-color brand icons and only maps monotone glyphs to
currentColor. Tinting the full-color ones (ByteDance/Kling/Luma/Gemini/Tencent)
replaced their artwork with a gradient square; only tint the monotone (solid)
providers now. Raise the Partner Nodes column header to 56px and unbold the
Partner Nodes / Members section titles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:14 -07:00
comfydesigner
fef7ad95f6 fix(dialog): clicking a menu trigger no longer dismisses the parent dialog
Reka treats the body-portaled dropdown content as outside the dialog, and the
outside-pointer handler only whitelisted overlay/menu content — not the trigger.
So reopening/closing a DropdownMenu (e.g. the Partner Nodes filter) via its
trigger dismissed the whole Settings dialog. Whitelist aria-haspopup triggers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:12 -07:00
comfydesigner
77624ad2b6 fix(ui): table scroll wrapper takes layout class; align auto-enable toggle
The Table component applied its class to the inner <table> instead of the scroll
wrapper, so the Partner Nodes scroll container was unconstrained and the sticky
header gap/height shifted on scroll. Apply the class to the wrapper and set
border-spacing:0 on the table so the sticky header sits flush. Pad the
auto-enable footer so its toggle lines up with the in-table row toggles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:11 -07:00
comfydesigner
2872fd4062 style(workspace): muted partner node names, disabled dim, brand-colored badges
Match the Figma partner nodes table: node names use muted-foreground; disabled
rows drop the name and partner to 30% opacity (date stays readable, toggle shows
its off state) instead of a flat row dim. Tint the monochrome provider icons
with their brand color (Anthropic coral #D97757, gradients like Kling painted
over the icon).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:09 -07:00
comfydesigner
6948ff3fc9 fix(workspace): partner nodes sticky header + divider
The Table component wraps its table in its own overflow-auto div, so nesting it
in a second scroll container made the sticky <thead> stick to the inner
non-scrolling div and scroll away. Use the Table as the single scroll container
and put the header divider on the <th> cells so it stays as the sticky header's
lower border. Move the empty state into a spanning row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:08 -07:00
comfydesigner
3e867da225 style(workspace): 16px table padding, circular partner badge with background
Reduce the Partner Nodes table inset from 24px to 16px per the Figma. Rebuild
the partner badge as a 20px circle (secondary-background-hover) with the 12px
provider icon centered so it fits without clipping, matching the Figma badge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:06 -07:00
comfydesigner
90d7d9f450 style(workspace): flat partner icons, ghost member-row overflow, members column divider
Partner badges now render the bare flat provider icon (no bordered pill). The
per-member overflow (...) button returns to the ghost variant. Add a divider
under the members column labels; the label row sits above the scroll area so it
stays visible while the list scrolls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:04 -07:00
comfydesigner
d52e97b0dc style(workspace): fix filter button shade, give members overflow button a fill
The Partner Nodes filter button dropped the lighter selected-surface override so
it uses the standard secondary surface, matching the adjacent search and the
dialog close button. The members-tab overflow (...) button gains the filled
surface used by the other overflow menus instead of being backgroundless.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:02 -07:00
comfydesigner
dc298c9ab8 feat(workspace): real provider icons on partner badges; keep selection after bulk toggle
Rebuild PartnerBadge on the shared BadgePill + getProviderIcon/getProviderBorderStyle
so partner rows show the same brand icons the node search menu uses, instead of
colored-initial placeholders.

Stop clearing the partner-node selection after a bulk enable/disable — the rows
stay selected so the switch can be flipped again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:05:00 -07:00
comfydesigner
9c3d2d3731 feat(workspace): Members header above card, list scrolls inside
Match the Partner Nodes layout: the search/invite/menu header sits above the
outlined card; the card fills the modal height and the members list scrolls
inside it instead of the whole card clipping. Footer stays below.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:59 -07:00
comfydesigner
6f44b98153 feat(workspace): Partner Nodes header above card, table scrolls inside; icon/label polish
Move the Partner Nodes title + search/filter above the outlined card; the card
now fills the modal height and the table scrolls inside it with a sticky header,
instead of the whole card clipping. Nav icons: Plan & Credits uses
lucide--receipt-text, Partner Nodes uses the comfy--node sidebar icon. Lowercase
'Partner nodes'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:57 -07:00
comfydesigner
51c213997f feat(workspace): real partner node list, brand badges, hover-reveal selection
Add shadcn-vue Checkbox and use it in the Partner Nodes table. Checkboxes are
hidden until a selection exists; with none selected they reveal on row hover
(the header select-all appears only once selecting). Give the filter button a
filled surface instead of the ghost variant. Add a PartnerBadge (brand colour +
initial) in the Partner column.

Populate the mock harness with the real Comfy Cloud partner-node catalog (96
nodes across 15 partners) pulled from the api-nodes list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:55 -07:00
comfydesigner
14c94879b1 fix(workspace): stabilize settings header height, lift Partner Nodes footer out of card
Use a uniform h-22 (88px) settings header on every tab so the 40px close button
sits a constant 24px from the top edge and no longer shifts when switching tabs;
the 48px workspace avatar centers ~20px from the top.

Move the Partner Nodes 'auto-enable new nodes' toggle outside the table card and
drop the bottom divider, matching the Figma (footer pinned to the panel bottom).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:54 -07:00
comfydesigner
fd6ec16c72 style(workspace): soften card outlines, unify overflow buttons to filled
Soften the workspace card borders (Plan & Credits, Members, Partner Nodes) to
interface-stroke/60 for a lighter panel. Standardize the members-row overflow
(...) button on the filled secondary style used in Plan & Credits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:52 -07:00
comfydesigner
974d79f776 feat(ui): shadcn Table for Partner Nodes, row-click select, fix Switch
Add a shadcn-vue Table component family (Table/Header/Body/Row/Head/Cell) and
rebuild the Partner Nodes table on it. Rows now hover and are clickable to
toggle their checkbox (a bigger target than the checkbox); the toggle cell and
checkbox stop propagation so they don't double-fire. Row dividers use the
interface-stroke token at reduced opacity for a subtler rule.

Fix the Switch: the thumb was bg-base-background (dark on a dark track, nearly
invisible) — now a white thumb on a primary/interface-stroke track.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:50 -07:00
comfydesigner
6636851f79 fix(workspace): give the settings header 24px top breathing room
Add an optional headerHeightClass to BaseModalLayout (default h-18, unchanged
for other dialogs). SettingDialog bumps the header row to h-24 on workspace
panels so the 48px avatar sits 24px from the top while staying aligned with
the close button; nav and content headers move together.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:49 -07:00
comfydesigner
3655901721 fix(workspace): align settings header with the dialog close button
Render the workspace avatar + name into the settings dialog's header row so
they sit on the same level as the close (X) button, matching Figma. Size the
avatar to 48px and the name to 24px/600 per the heading-text-medium spec.

Also fix WorkspaceProfilePic to merge its base classes via cn() so a caller's
size utility (e.g. size-12) reliably wins over the component's default size-8
instead of colliding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:47 -07:00
comfydesigner
6d91432abe chore(billing): trim mock harness to workspace-relevant controls (do not merge)
Remove the on/off toggle (harness is always active once opted in via ?billingmock;
the X still fully deactivates it) and the V0 checkout/op-flow controls
(subscribe, topup, op-poll). Keep ws, role, tier, state, balance, roleChange,
and the 2nd-workspace switcher toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:45 -07:00
comfydesigner
2b8981a2d3 feat(workspace): split settings into sidebar entries + Partner Nodes governance
Break the combined Workspace settings panel into three Settings sidebar
entries: Plan & Credits (keeps the 'workspace' key so deep links land),
Members, and a new Partner Nodes governance screen. Partner Nodes is gated to
Owner + Admins via a new canManagePartnerNodes permission; Members never see it.

Partner Nodes: per-node + bulk enable/disable, search, partner filter, sort,
and a workspace default for auto-enabling newly added nodes, backed by a new
partnerNodesApi + usePartnerNodes composable with optimistic updates. Adds a
reusable Switch. Mock routes added to the (do-not-merge) billing harness.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:43 -07:00
comfydesigner
4779a21ce5 chore(billing): restore preview billing mock harness (do not merge)
Dev-only harness for exercising billing/workspace UX on a cloud proxy with no
BE writes. No-op unless opted in via ?billingmock. Restored from 75da9212d^ to
back the V1 team-workspace prototype; must be excluded from the split PRs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:42 -07:00
comfydesigner
6b0e12a783 feat(workspace): rename non-creator Owner role to Admin
Reserve 'Owner' for the workspace creator (is_original_owner); every other
owner-role member now displays as 'Admin' in the members table and role menu.
Display-layer only — the backend role enum is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:04:40 -07:00
264 changed files with 13626 additions and 10706 deletions

View File

@@ -63,14 +63,3 @@ reviews:
Pass if none of these patterns are found in the diff.
When warning, reference the specific ADR by number and link to `docs/adr/` for context. Frame findings as directional guidance since ADR 0003 and 0008 are in Proposed status.
path_instructions:
- path: '**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/vitest.md` as required review context for every changed Vitest test file.
- path: 'src/lib/litegraph/**/*.test.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, `docs/guidance/vitest.md`, and `docs/testing/litegraph-testing.md` as required review context for every changed litegraph Vitest test file.
- path: '{browser_tests,apps/website/e2e}/**/*.spec.ts'
instructions: |
Treat `.agents/checks/test-quality.md`, `docs/testing/README.md`, and `docs/guidance/playwright.md` as required review context for every changed Playwright test file.

View File

@@ -29,7 +29,7 @@ jobs:
# SHA-pinned per zizmor `unpinned-uses: hash-pin`. Bump this SHA to pick up
# upstream changes; keep `workflows_ref` matching so prompts/scripts load
# from the same commit as the workflow definition.
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@df507e6bae179c567ad3849370f99dae588985dc # github-workflows main (df507e6)
uses: Comfy-Org/github-workflows/.github/workflows/cursor-review.yml@047ca48febe3a6647608ed2e0c4331b491cb9d6a # github-workflows#9
with:
# Overriding diff_excludes replaces the reusable default wholesale, so
# this restates the generated/vendored defaults and adds this repo's heavy
@@ -48,7 +48,7 @@ jobs:
:!**/*-snapshots/**
:!src/workbench/extensions/manager/types/generatedManagerTypes.ts
# Load the prompts/scripts from the same ref as `uses:`.
workflows_ref: df507e6bae179c567ad3849370f99dae588985dc
workflows_ref: 047ca48febe3a6647608ed2e0c4331b491cb9d6a
secrets:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
# Optional — enables start/complete Slack DMs to the triggerer.

View File

@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
}
})
test('AIModelsSection heading and 6 model cards are visible', async ({
test('AIModelsSection heading and 5 model cards are visible', async ({
page
}) => {
const heading = page.getByRole('heading', { name: /leading AI models/i })
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
const section = heading.locator('xpath=ancestor::section')
const grid = section.locator('.grid')
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
await expect(modelCards).toHaveCount(6)
await expect(modelCards).toHaveCount(5)
})
test('AIModelsSection CTA links to workflows', async ({ page }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 87 KiB

View File

@@ -33,7 +33,7 @@ const ctaButtons = [
<template>
<nav
class="sticky top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
class="fixed inset-x-0 top-0 z-50 flex items-center justify-between gap-4 bg-primary-comfy-ink px-6 py-5 lg:gap-4 lg:px-[clamp(0.25rem,4vw,5rem)] lg:py-8"
aria-label="Main navigation"
>
<a

View File

@@ -30,12 +30,7 @@ const { title, description, cta, href, bg } = defineProps<{
<p class="text-sm text-white/70">
{{ description }}
</p>
<Button
as="span"
variant="default"
size="sm"
class="mt-4 h-auto whitespace-normal"
>
<Button as="span" variant="default" size="sm" class="mt-4">
{{ cta }}
</Button>
</div>

View File

@@ -86,7 +86,6 @@ const companyColumn: { title: string; links: FooterLink[] } = {
{ label: t('footer.about', locale), href: routes.about },
{ label: t('nav.careers', locale), href: routes.careers },
{ label: t('footer.termsOfService', locale), href: routes.termsOfService },
{ label: t('footer.enterpriseMsa', locale), href: routes.enterpriseMsa },
{ label: t('footer.privacyPolicy', locale), href: routes.privacyPolicy }
]
}

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { Locale, TranslationKey } from '../../i18n/translations'
import { localizeHref } from '../../config/routes'
import { t } from '../../i18n/translations'
const {
@@ -16,7 +15,8 @@ const {
locale?: Locale
}>()
const nextHref = localizeHref(`/demos/${nextSlug}`, locale)
const localePrefix = locale === 'en' ? '' : `/${locale}`
const nextHref = `${localePrefix}/demos/${nextSlug}`
</script>
<template>

View File

@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest'
import { getRoutes } from '../../config/routes'
import { hasKey, translationKeys } from '../../i18n/translations'
const PREFIX = 'enterprise-msa'
function deriveMsaSectionIds(): string[] {
const labelRegex = new RegExp(`^${PREFIX}\\.([0-9]+-[a-z-]+)\\.label$`)
const ids: string[] = []
for (const key of translationKeys) {
const match = key.match(labelRegex)
if (match && !ids.includes(match[1])) ids.push(match[1])
}
return ids
}
describe('enterprise MSA i18n', () => {
it('every derived section has a title and at least one block', () => {
const sectionIds = deriveMsaSectionIds()
expect(sectionIds.length).toBeGreaterThan(0)
for (const id of sectionIds) {
expect(hasKey(`${PREFIX}.${id}.title`)).toBe(true)
expect(hasKey(`${PREFIX}.${id}.block.0`)).toBe(true)
}
})
it('exposes the page-chrome keys the .astro file references', () => {
for (const suffix of [
'effective-date',
'page.title',
'page.description',
'page.heading',
'page.tocLabel',
'page.effectiveDateLabel',
'page.parties'
]) {
expect(hasKey(`${PREFIX}.${suffix}`)).toBe(true)
}
})
it('serves the enterprise MSA at the canonical /enterprise-msa path regardless of locale', () => {
expect(getRoutes('en').enterpriseMsa).toBe('/enterprise-msa')
expect(getRoutes('zh-CN').enterpriseMsa).toBe('/enterprise-msa')
})
})

View File

@@ -1,10 +1,7 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { Check, Copy } from '@lucide/vue'
import { useClipboard } from '@vueuse/core'
import { computed } from 'vue'
// Interactive: the copy button is inert until its host island is hydrated.
// Render under a `client:*` directive (e.g. `client:visible`) when the page
// needs it to work.
@@ -14,8 +11,6 @@ const {
copiedLabel = 'Copied'
} = defineProps<{ value: string; copyLabel?: string; copiedLabel?: string }>()
const multiline = computed(() => value.includes('\n'))
const { copy, copied } = useClipboard({ copiedDuring: 2000 })
function handleCopy() {
@@ -25,32 +20,15 @@ function handleCopy() {
<template>
<div
:class="
cn(
'bg-transparency-white-t4 border-primary-warm-gray flex gap-2 rounded-xl border px-4 py-3',
multiline ? 'items-start' : 'items-center'
)
"
class="bg-transparency-white-t4 border-primary-warm-gray flex items-center gap-2 rounded-xl border px-4 py-3"
>
<span
:class="
cn(
'flex-1 font-mono text-xs text-primary-comfy-canvas',
multiline ? 'wrap-break-word whitespace-pre-line' : 'truncate'
)
"
>
<span class="flex-1 truncate font-mono text-xs text-primary-comfy-canvas">
{{ value }}
</span>
<button
type="button"
:aria-label="copied ? copiedLabel : copyLabel"
:class="
cn(
'text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas',
multiline && 'mt-0.5'
)
"
class="text-primary-warm-gray shrink-0 cursor-pointer transition-colors hover:text-primary-comfy-canvas"
@click="handleCopy"
>
<component :is="copied ? Check : Copy" class="size-4" />

View File

@@ -1,38 +0,0 @@
<script setup lang="ts">
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import type { IconButtonVariants } from '.'
import { Primitive } from 'reka-ui'
import { cn } from '@comfyorg/tailwind-utils'
import { iconButtonVariants } from '.'
interface Props extends PrimitiveProps {
variant?: IconButtonVariants['variant']
size?: IconButtonVariants['size']
class?: HTMLAttributes['class']
disabled?: boolean
}
const {
as = 'button',
asChild,
variant,
size,
class: className,
disabled
} = defineProps<Props>()
</script>
<template>
<Primitive
data-slot="icon-button"
:data-variant="variant"
:data-size="size"
:as
:as-child
:disabled
:class="cn(iconButtonVariants({ variant, size }), className)"
>
<slot />
</Primitive>
</template>

View File

@@ -1,28 +0,0 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export const iconButtonVariants = cva(
[
'focus-visible:border-primary-comfy-yellow focus-visible:ring-primary-comfy-yellow/50 inline-flex shrink-0 cursor-pointer items-center justify-center rounded-2xl transition-all duration-200 outline-none focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0'
],
{
variants: {
variant: {
ghost:
'text-primary-warm-white hover:text-primary-comfy-yellow bg-transparent',
outline:
'text-primary-comfy-yellow hover:bg-primary-comfy-yellow border-primary-comfy-yellow border-2 bg-primary-comfy-ink hover:text-primary-comfy-ink'
},
size: {
sm: 'size-8',
default: 'size-10',
lg: 'size-14'
}
},
defaultVariants: {
variant: 'ghost',
size: 'default'
}
}
)
export type IconButtonVariants = VariantProps<typeof iconButtonVariants>

View File

@@ -1,75 +0,0 @@
import { onMounted, ref } from 'vue'
import { BANNER_DISMISS_ATTR, BANNER_STORAGE_KEY } from '../utils/banner'
type ClosedBanners = Record<string, boolean>
function readClosedBanners(): ClosedBanners {
try {
const raw = localStorage.getItem(BANNER_STORAGE_KEY)
return raw ? (JSON.parse(raw) as ClosedBanners) : {}
} catch {
return {}
}
}
function writeClosedBanners(value: ClosedBanners): void {
try {
localStorage.setItem(BANNER_STORAGE_KEY, JSON.stringify(value))
} catch {
// Storage unavailable (private mode / quota) — dismissal just won't persist.
}
}
/** The stable part of a version key (everything before `_v<hash>`). */
function versionPrefix(version: string): string {
const idx = version.lastIndexOf('_v')
return idx === -1 ? version : version.slice(0, idx)
}
/**
* Client-side dismissal persisted in localStorage, keyed by a content-aware
* `version`. The banner renders visible in the static HTML (so non-dismissers
* see no pop-in); an inline pre-hydration script hides an already-dismissed
* banner before paint, and this composable then removes it from the DOM on mount.
*/
export function useBannerDismissal(version: string) {
const isVisible = ref(true)
onMounted(() => {
const stored = readClosedBanners()
const prefix = versionPrefix(version)
// Prune stale versions of THIS banner+locale; keep other banners/locales
// and the current version.
const cleaned: ClosedBanners = Object.create(null) as ClosedBanners
let pruned = false
for (const key of Object.keys(stored)) {
if (versionPrefix(key) !== prefix || key === version) {
cleaned[key] = stored[key]
} else {
pruned = true
}
}
if (pruned) writeClosedBanners(cleaned)
isVisible.value = !cleaned[version]
})
function close(): void {
isVisible.value = false
const stored = readClosedBanners()
stored[version] = true
writeClosedBanners(stored)
}
// Call once the close transition has finished. Sets the pre-paint hide signal
// so the banner doesn't flash back in on a ClientRouter (view-transition)
// navigation — where the inline <head> script does not re-run but <html>
// persists. Deferred to after the animation so the leave transition can play.
function persistHidden(): void {
document.documentElement.setAttribute(BANNER_DISMISS_ATTR, '')
}
return { isVisible, close, persistHidden }
}

View File

@@ -1,31 +0,0 @@
import { describe, expect, it } from 'vitest'
import { isHrefActive } from './useCurrentPath'
describe('isHrefActive', () => {
it('matches the current page', () => {
expect(isHrefActive('/mcp', '/mcp')).toBe(true)
})
it('does not match other pages', () => {
expect(isHrefActive('/mcp', '/pricing')).toBe(false)
})
it('matches regardless of a trailing slash', () => {
expect(isHrefActive('/mcp', '/mcp/')).toBe(true)
})
it('ignores query and hash on the href', () => {
expect(isHrefActive('/mcp?ref=banner#setup', '/mcp')).toBe(true)
})
it('never matches an external href', () => {
expect(
isHrefActive('https://docs.comfy.org/agent-tools/cloud', '/mcp')
).toBe(false)
})
it('never matches an empty href', () => {
expect(isHrefActive('', '/mcp')).toBe(false)
})
})

View File

@@ -1,85 +0,0 @@
import type { ButtonVariants } from '../components/ui/button'
import type { Locale, TranslationKey } from '../i18n/translations'
import { t } from '../i18n/translations'
import { resolveRel } from '../utils/cta'
import { localizeHref } from './routes'
// The banner "CMS": a single typed config resolved through i18n at build time.
// `isActive` is the master on/off switch (supersedes the old SHOW_ANNOUNCEMENT_BANNER).
// NOTE: on this static site, `startsAt`/`endsAt` are evaluated at BUILD time — the
// window gates on the last deploy, not the visitor's exact clock.
interface BannerLinkConfig {
readonly href: string
readonly titleKey: TranslationKey
readonly target?: boolean
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
}
export interface BannerConfig {
readonly id: string
readonly isActive: boolean
readonly startsAt?: string
readonly endsAt?: string
/** Empty/undefined = all locales. */
readonly targetLocales?: readonly Locale[]
/** v1 only supports 'sitewide'. */
readonly targetSections?: readonly string[]
readonly titleKey: TranslationKey
readonly descriptionKey?: TranslationKey
readonly link?: BannerLinkConfig
}
interface BannerLinkData {
readonly href: string
readonly title: string
readonly target?: '_blank'
readonly rel?: string
readonly buttonVariant?: NonNullable<ButtonVariants['variant']>
}
export interface BannerData {
readonly id: string
readonly title: string
readonly description?: string
readonly link?: BannerLinkData
}
export const bannerConfig: BannerConfig = {
id: 'announcement',
isActive: true,
targetSections: ['sitewide'],
titleKey: 'launches.banner.text',
link: {
href: '/mcp',
titleKey: 'launches.banner.cta',
buttonVariant: 'underlineLink'
}
}
/** Resolve a config's i18n keys into display strings for the given locale. */
export function getBannerData(
config: BannerConfig,
locale: Locale
): BannerData {
const { link } = config
const target = link?.target ? '_blank' : undefined
return {
id: config.id,
title: t(config.titleKey, locale),
description: config.descriptionKey
? t(config.descriptionKey, locale)
: undefined,
link: link
? {
href: localizeHref(link.href, locale),
title: t(link.titleKey, locale),
target,
rel: resolveRel({ target: target ?? '_self' }),
buttonVariant: link.buttonVariant
}
: undefined
}
}

View File

@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest'
import { localizeHref } from './routes'
describe('localizeHref', () => {
it('prefixes an internal path for a non-default locale', () => {
expect(localizeHref('/mcp', 'zh-CN')).toBe('/zh-CN/mcp')
})
it('leaves the default locale unprefixed', () => {
expect(localizeHref('/mcp', 'en')).toBe('/mcp')
})
it('passes external URLs through unchanged', () => {
expect(
localizeHref('https://docs.comfy.org/agent-tools/cloud', 'zh-CN')
).toBe('https://docs.comfy.org/agent-tools/cloud')
})
it('never prefixes locale-invariant routes', () => {
expect(localizeHref('/terms-of-service', 'zh-CN')).toBe('/terms-of-service')
})
})

View File

@@ -15,7 +15,6 @@ const baseRoutes = {
demos: '/demos',
learning: '/learning',
termsOfService: '/terms-of-service',
enterpriseMsa: '/enterprise-msa',
privacyPolicy: '/privacy-policy',
affiliates: '/affiliates',
affiliateTerms: '/affiliates/terms',
@@ -36,37 +35,19 @@ type Routes = typeof baseRoutes
// block in src/i18n/translations.ts for the reasoning.
//
// termsOfService: legal-reviewed English-only document, same reasoning.
//
// enterpriseMsa: legal-reviewed English-only document (Comfy Enterprise
// Customer Agreement template), same reasoning. See the comment header
// in src/pages/enterprise-msa.astro.
const LOCALE_INVARIANT_ROUTE_KEYS = new Set<keyof Routes>([
'affiliates',
'affiliateTerms',
'termsOfService',
'enterpriseMsa'
'termsOfService'
])
const LOCALE_INVARIANT_PATHS = new Set<string>(
[...LOCALE_INVARIANT_ROUTE_KEYS].map((key) => baseRoutes[key])
)
/**
* Prefix an internal path with the locale (`/mcp` → `/zh-CN/mcp`). External
* URLs and locale-invariant routes pass through unchanged.
*/
export function localizeHref(href: string, locale: Locale = 'en'): string {
if (locale === 'en' || !href.startsWith('/')) return href
if (LOCALE_INVARIANT_PATHS.has(href)) return href
return `/${locale}${href}`
}
export function getRoutes(locale: Locale = 'en'): Routes {
if (locale === 'en') return baseRoutes
const prefix = `/${locale}`
return Object.fromEntries(
Object.entries(baseRoutes).map(([key, path]) => [
key,
localizeHref(path, locale)
Object.entries(baseRoutes).map(([k, v]) => [
k,
LOCALE_INVARIANT_ROUTE_KEYS.has(k as keyof Routes) ? v : `${prefix}${v}`
])
) as unknown as Routes
}
@@ -79,12 +60,13 @@ export const externalLinks = {
cloudStatus: 'https://status.comfy.org',
discord: 'https://discord.com/invite/comfyorg',
docs: 'https://docs.comfy.org/',
docsApi: 'https://docs.comfy.org/development/cloud/overview#quick-start',
docsApi: 'https://docs.comfy.org/api-reference/cloud',
docsMcp: 'https://docs.comfy.org/agent-tools/cloud',
docsSubscription: 'https://docs.comfy.org/support/subscription/subscribing',
github: 'https://github.com/Comfy-Org/ComfyUI',
githubInstall: 'https://github.com/Comfy-Org/ComfyUI#installing',
instagram: 'https://www.instagram.com/comfyui/',
mcpServer: 'https://cloud.comfy.org/mcp',
mcpSkills: 'https://github.com/Comfy-Org/comfy-skills',
platform: 'https://platform.comfy.org',
platformUsage: 'https://platform.comfy.org/profile/usage',

View File

@@ -72,24 +72,6 @@ export const drops: readonly Drop[] = [
href: { en: '/download', 'zh-CN': '/zh-CN/download' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'app-mode',
badge: NEW_BADGE,
@@ -130,6 +112,24 @@ export const drops: readonly Drop[] = [
href: { en: '/api', 'zh-CN': '/zh-CN/api' }
}
},
{
id: 'comfy-mcp',
badge: NEW_BADGE,
category: CLOUD,
media: imageFor('Drops_2x2card_MCP.jpg', {
en: 'Comfy MCP',
'zh-CN': 'Comfy MCP'
}),
title: { en: 'Comfy MCP', 'zh-CN': 'Comfy MCP' },
description: {
en: 'The full power of ComfyUI from anywhere — no setup, no GPU required.',
'zh-CN': '随时随地体验 ComfyUI 的全部能力 — 无需配置,无需 GPU。'
},
cta: {
label: EXPLORE,
href: { en: '/mcp', 'zh-CN': '/zh-CN/mcp' }
}
},
{
id: 'community-workflows',
category: COMMUNITY,

View File

@@ -1872,10 +1872,6 @@ const translations = {
en: 'VIEW DOCS',
'zh-CN': '查看文档'
},
'mcp.hero.installMcp': {
en: 'INSTALL MCP',
'zh-CN': '安装 MCP'
},
'mcp.hero.runWorkflow': {
en: 'RUN A WORKFLOW',
'zh-CN': '运行工作流'
@@ -1913,27 +1909,21 @@ const translations = {
},
'mcp.setup.step1.label': { en: 'STEP 1', 'zh-CN': '第 1 步' },
'mcp.setup.step1.title': {
en: 'Ask your agent to install Comfy MCP',
'zh-CN': '让你的智能体安装 Comfy MCP'
},
'mcp.setup.step1.command': {
en: 'Help me install Comfy MCP.\nFollow the setup guide at {url}',
'zh-CN': '帮我安装 Comfy MCP。\n请按照 {url} 上的设置指南操作。'
en: 'Copy the MCP URL',
'zh-CN': '复制 MCP URL'
},
'mcp.setup.step1.description': {
en: 'Paste this into Claude, Cursor, Codex, or any MCP-compatible agent. It reads the docs and adds the connector for you.',
'zh-CN':
'将它粘贴到 Claude、Cursor、Codex 或任意兼容 MCP 的智能体中。它会读取文档并为你添加连接器。'
en: "Click the copy button below. You'll paste it into your client in the next step.",
'zh-CN': '点击下方的复制按钮,下一步将其粘贴到你的客户端中。'
},
'mcp.setup.step2.label': { en: 'STEP 2', 'zh-CN': '第 2 步' },
'mcp.setup.step2.title': {
en: 'Or add it by hand',
'zh-CN': '或手动添加'
en: 'Add the connector',
'zh-CN': '添加连接器'
},
'mcp.setup.step2.description': {
en: 'Prefer manual setup? Add Comfy Cloud as a custom connector with the MCP URL. The docs cover every client.',
'zh-CN':
'想手动配置?用 MCP URL 将 Comfy Cloud 添加为自定义连接器。文档涵盖各类客户端。'
en: 'Name it Comfy Cloud and paste the URL. The docs below cover every client.',
'zh-CN': '将其命名为 Comfy Cloud 并粘贴 URL。下方文档涵盖各类客户端。'
},
'mcp.setup.step2.cta': {
en: 'COMFY CLOUD MCP DOCS',
@@ -3496,429 +3486,6 @@ const translations = {
'zh-CN': '生效日期'
},
// ── Enterprise MSA ─────────────────────────────────────────────────
// English-only, by design. This is a legal-reviewed customer-facing
// template. Serving a translated variant would expose Comfy to
// liability from the translation diverging from the approved English
// source. See the matching header comment in
// src/pages/enterprise-msa.astro and the LOCALE_INVARIANT_ROUTE_KEYS
// entry in src/config/routes.ts.
'enterprise-msa.effective-date': {
en: 'May 22, 2026',
'zh-CN': 'May 22, 2026'
},
'enterprise-msa.1-definitions.label': {
en: 'DEFINITIONS',
'zh-CN': 'DEFINITIONS'
},
'enterprise-msa.1-definitions.title': {
en: '1. Definitions',
'zh-CN': '1. Definitions'
},
'enterprise-msa.1-definitions.block.0': {
en: '<strong>“Affiliates”</strong> means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.',
'zh-CN':
'<strong>“Affiliates”</strong> means any entity that directly or indirectly controls, is controlled by, or is under common control with a party, where “control” means the ownership of more than fifty percent (50%) of the voting securities or other voting interests of such entity.'
},
'enterprise-msa.1-definitions.block.1': {
en: '<strong>“Applicable Laws”</strong> means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.',
'zh-CN':
'<strong>“Applicable Laws”</strong> means all federal and state laws, treaties, rules, regulations, regulatory and supervisory guidance, directives, policies, orders or determinations of a regulatory authority applicable to the activities and obligations contemplated under this Agreement.'
},
'enterprise-msa.1-definitions.block.2': {
en: '<strong>“Comfy API”</strong> means the application programming interface and related developer tools made available by Comfy that allows Customer to access and execute visual AI workflows programmatically as production endpoints from within Customers own applications or systems.',
'zh-CN':
'<strong>“Comfy API”</strong> means the application programming interface and related developer tools made available by Comfy that allows Customer to access and execute visual AI workflows programmatically as production endpoints from within Customers own applications or systems.'
},
'enterprise-msa.1-definitions.block.3': {
en: '<strong>“Comfy Branding”</strong> means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.',
'zh-CN':
'<strong>“Comfy Branding”</strong> means the names, logos, and associated trademarks owned or in progress of being owned by Comfy.'
},
'enterprise-msa.1-definitions.block.4': {
en: '<strong>“Comfy Cloud”</strong> means the cloud-based hosting environment made available by Comfy that allows Customer to access and run visual AI workflows remotely through Comfys infrastructure, without requiring local installation or hardware.',
'zh-CN':
'<strong>“Comfy Cloud”</strong> means the cloud-based hosting environment made available by Comfy that allows Customer to access and run visual AI workflows remotely through Comfys infrastructure, without requiring local installation or hardware.'
},
'enterprise-msa.1-definitions.block.5': {
en: '<strong>“Comfy Enterprise”</strong> means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.',
'zh-CN':
'<strong>“Comfy Enterprise”</strong> means the enterprise-grade product tier made available by Comfy that provides organizations with dedicated infrastructure, enhanced security, administrative controls, and related support services for deploying and managing visual AI workflows at scale.'
},
'enterprise-msa.1-definitions.block.6': {
en: '<strong>“Comfy OSS”</strong> means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under this Agreement or in connection with any commercial product or offering.',
'zh-CN':
'<strong>“Comfy OSS”</strong> means the open-source software, source code, libraries, tools, and related components made available by Comfy under one or more open source licenses, including the software repositories published by Comfy at <a href="https://github.com/Comfy-Org" class="text-white underline">https://github.com/Comfy-Org</a>, as updated, modified, or supplemented from time to time. For the avoidance of doubt, Comfy OSS does not include any proprietary software, infrastructure, or functionality made available by Comfy under this Agreement or in connection with any commercial product or offering.'
},
'enterprise-msa.1-definitions.block.7': {
en: '<strong>“Comfy Products”</strong> means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to Customer under this Agreement, excluding any Comfy OSS.',
'zh-CN':
'<strong>“Comfy Products”</strong> means Comfy Cloud, Comfy API, Comfy Enterprise and other products, software, features, tools, and functionality made available by Comfy to Customer under this Agreement, excluding any Comfy OSS.'
},
'enterprise-msa.1-definitions.block.8': {
en: '<strong>“Customer Data”</strong> means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.',
'zh-CN':
'<strong>“Customer Data”</strong> means electronic data and information submitted or generated by Customer in connection with its use of the Comfy Products, including all Inputs and Outputs.'
},
'enterprise-msa.1-definitions.block.9': {
en: '<strong>“Open Source License”</strong> means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.',
'zh-CN':
'<strong>“Open Source License”</strong> means the open source license(s) under which Comfy makes Comfy OSS available, as identified in the applicable source code repository.'
},
'enterprise-msa.1-definitions.block.10': {
en: '<strong>“Operational Metadata”</strong> means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.',
'zh-CN':
'<strong>“Operational Metadata”</strong> means usage and diagnostic information generated by the Comfy Products and collected by Comfy to support, maintain, and optimize the performance and security of the Comfy Products, including information regarding software versions, system configuration, uptime, error logs, health metrics, and feature usage. Operational Metadata does not include Customer Data or Confidential Information.'
},
'enterprise-msa.1-definitions.block.11': {
en: '<strong>“Order Form”</strong> means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement. The initial Order Form is attached as Exhibit A.',
'zh-CN':
'<strong>“Order Form”</strong> means the online sign-up flow, order form or other ordering document entered into or otherwise agreed by Customer that references this Agreement. The initial Order Form is attached as Exhibit A.'
},
'enterprise-msa.1-definitions.block.12': {
en: '<strong>“User”</strong> means Customers or Customers Affiliates employees and contractors who are authorized by Customer to access and use the Comfy Products on Customers or Customers Affiliates behalf according to the terms of this Agreement.',
'zh-CN':
'<strong>“User”</strong> means Customers or Customers Affiliates employees and contractors who are authorized by Customer to access and use the Comfy Products on Customers or Customers Affiliates behalf according to the terms of this Agreement.'
},
'enterprise-msa.2-comfy-products.label': {
en: 'PRODUCTS',
'zh-CN': 'PRODUCTS'
},
'enterprise-msa.2-comfy-products.title': {
en: '2. Comfy Products',
'zh-CN': '2. Comfy Products'
},
'enterprise-msa.2-comfy-products.block.0': {
en: '<strong>Right to Access and Use Comfy Products.</strong> Subject to Customers compliance with all of the terms and conditions of this Agreement, Comfy grants Customer and Customers Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for Customers internal business purposes.',
'zh-CN':
'<strong>Right to Access and Use Comfy Products.</strong> Subject to Customers compliance with all of the terms and conditions of this Agreement, Comfy grants Customer and Customers Users a non-exclusive, non-sublicensable, non-transferable right during the term of this Agreement to access and use the Comfy Products as set forth in the applicable Order Form for Customers internal business purposes.'
},
'enterprise-msa.2-comfy-products.block.1': {
en: '<strong>Customer Data.</strong> As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customers use of the Comfy Products as a result of processing Customers Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.',
'zh-CN':
'<strong>Customer Data.</strong> As between Comfy and Customer, Customer retains all right, title, and interest in and to any data, images, videos, prompts, models, workflows, nodes, parameters, or other materials submitted or uploaded by Customer to the Comfy Products (“Input”), as well as any images, videos, designs, or other visual content generated through Customers use of the Comfy Products as a result of processing Customers Input (“Output”). Customer acknowledges that due to the nature of artificial intelligence, Comfy may generate the same or similar Output for other customers, and Customer shall have no right, title, or interest in or to Output generated for any other customer.'
},
'enterprise-msa.2-comfy-products.block.2': {
en: '<strong>No AI Training.</strong> Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customers use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.',
'zh-CN':
'<strong>No AI Training.</strong> Comfy will not use Input or Output to train generative AI or diffusion models. Comfy may, however, collect and use limited metadata derived from Customers use of the Comfy Products, such as prompt classifications, workflow structures, and node configurations, to improve the performance, functionality, and user experience of the Comfy Products.'
},
'enterprise-msa.2-comfy-products.block.3': {
en: '<strong>Comfy OSS.</strong> Customer may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under this Agreement. Nothing in this Agreement shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If Customer chooses to use the Comfy Products in conjunction with Comfy OSS, this Agreement applies solely to Customers use of the Comfy Products and not to the Comfy OSS itself.',
'zh-CN':
'<strong>Comfy OSS.</strong> Customer may use Comfy OSS under the terms of the applicable Open Source License(s) governing each respective component, as identified in the corresponding source code repository, rather than under this Agreement. Nothing in this Agreement shall be construed to limit, supersede, or modify any rights or obligations arising under an applicable Open Source License. If Customer chooses to use the Comfy Products in conjunction with Comfy OSS, this Agreement applies solely to Customers use of the Comfy Products and not to the Comfy OSS itself.'
},
'enterprise-msa.2-comfy-products.block.4': {
en: '<strong>Partner Nodes.</strong> Certain features of the Comfy Products allow Customer to access third-party AI model providers (“Partner Nodes”) through Comfy. When Customer uses a Partner Node, Comfy proxies Customers request to the applicable third-party provider, transmitting the information necessary to fulfill Customers request, including prompts, images, models, and parameters. Comfy does not transmit Customers identity or account information to third-party providers in connection with Partner Node requests. Customers use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.',
'zh-CN':
'<strong>Partner Nodes.</strong> Certain features of the Comfy Products allow Customer to access third-party AI model providers (“Partner Nodes”) through Comfy. When Customer uses a Partner Node, Comfy proxies Customers request to the applicable third-party provider, transmitting the information necessary to fulfill Customers request, including prompts, images, models, and parameters. Comfy does not transmit Customers identity or account information to third-party providers in connection with Partner Node requests. Customers use of Partner Nodes is subject to the terms and policies of the applicable third-party provider, and Comfy is not responsible for the data practices of such providers. Usage of Partner Nodes is metered and billed through Comfy.'
},
'enterprise-msa.2-comfy-products.block.5': {
en: '<strong>Modification of Comfy Products.</strong> Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.',
'zh-CN':
'<strong>Modification of Comfy Products.</strong> Comfy may, at any time and in its sole discretion, modify, update, enhance, restrict, suspend, or discontinue the Comfy Products, in whole or in part, including by changing or removing features, functionality, endpoints, specifications, documentation, access methods, usage limits, or availability. Comfy has no obligation to maintain or support any particular version of the Comfy Products or to ensure backward compatibility. Any such modifications may be made with or without notice and may result in interruptions to or degradation of the Comfy Products. Comfy shall have no liability arising out of or related to any modification, suspension, or discontinuation of the Comfy Products, and Customer acknowledges that its use of the Comfy Products is at its own risk and that it should not rely on the continued availability of any aspect of the Comfy Products.'
},
'enterprise-msa.2-comfy-products.block.6': {
en: '<strong>Data Retention and Deletion.</strong> Comfy retains Customer Data for as long as Customers account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfys retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. Customer may request deletion of Customers account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify Customers personal information from its primary systems within a reasonable time. Customer acknowledges that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from Customers use of the Comfy Products may be retained indefinitely.',
'zh-CN':
'<strong>Data Retention and Deletion.</strong> Comfy retains Customer Data for as long as Customers account remains active or as otherwise necessary to provide the Comfy Products, comply with applicable legal obligations, resolve disputes, and enforce this Agreement. Specific retention periods for different categories of Customer Data are set forth in Comfys retention documentation, available at <a href="https://docs.comfy.org/support/data-retention" class="text-white underline">docs.comfy.org/support/data-retention</a>, as updated from time to time. Customer may request deletion of Customers account and associated Customer Data by contacting Comfy at <a href="mailto:legal@comfy.org" class="text-white underline">legal@comfy.org</a>. Upon receipt of a verified deletion request, Comfy will use commercially reasonable efforts to delete or de-identify Customers personal information from its primary systems within a reasonable time. Customer acknowledges that: (i) deletion may not propagate immediately to all backup systems, third-party analytics providers, or observability systems, which retain data subject to their own retention policies; (ii) certain Customer Data may be retained as required by applicable law or for legitimate business purposes such as billing records; and (iii) aggregated or de-identified data derived from Customers use of the Comfy Products may be retained indefinitely.'
},
'enterprise-msa.3-customer-responsibilities.label': {
en: 'CUSTOMER',
'zh-CN': 'CUSTOMER'
},
'enterprise-msa.3-customer-responsibilities.title': {
en: '3. Customer Responsibilities',
'zh-CN': '3. Customer Responsibilities'
},
'enterprise-msa.3-customer-responsibilities.block.0': {
en: '<strong>Registration.</strong> To access and use the Comfy Products, Customer may be required to register one or more accounts by providing Comfy with the information specified in the applicable registration form, including Customers email address. Customer shall ensure that all registration information provided to Comfy is complete and accurate, and shall promptly update such information as necessary to keep it current. Customer shall be liable for all activities conducted through its account, including any unauthorized access or use resulting from Customers failure to implement reasonable access controls or to limit access to its systems and devices.',
'zh-CN':
'<strong>Registration.</strong> To access and use the Comfy Products, Customer may be required to register one or more accounts by providing Comfy with the information specified in the applicable registration form, including Customers email address. Customer shall ensure that all registration information provided to Comfy is complete and accurate, and shall promptly update such information as necessary to keep it current. Customer shall be liable for all activities conducted through its account, including any unauthorized access or use resulting from Customers failure to implement reasonable access controls or to limit access to its systems and devices.'
},
'enterprise-msa.3-customer-responsibilities.block.1': {
en: '<strong>General Technology Restrictions.</strong> Customer agrees that it will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfys products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.',
'zh-CN':
'<strong>General Technology Restrictions.</strong> Customer agrees that it will not, directly or indirectly: (i) sublicense the Comfy Products for use by a third party; (ii) reverse engineer or attempt to extract the source code or underlying methodology from the Comfy Products or any related software, except to the extent that this restriction is expressly prohibited by Applicable Laws; (iii) use or facilitate the use of the Comfy Products for any activities that are prohibited by Applicable Laws or otherwise; (iv) bypass or circumvent measures employed to prevent or limit access to the Comfy Products; (v) use the Comfy Products to create a product or service competitive with Comfys products or services; (vi) create derivative works of or otherwise create, attempt to create or derive, or knowingly assist any third party to create or derive, the source code underlying the Comfy Products; or (vii) otherwise use or interact with the Comfy Products for any purpose not expressly permitted under this Agreement.'
},
'enterprise-msa.3-customer-responsibilities.block.2': {
en: '<strong>Acceptable Use; Prohibited Customer Data.</strong> Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfys systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.',
'zh-CN':
'<strong>Acceptable Use; Prohibited Customer Data.</strong> Customer is solely responsible for ensuring that all Input submitted to the Comfy Products complies with all Applicable Laws, and Customer agrees that it will not, and will not permit any third party to submit to Comfy or the Comfy Products or otherwise use the Comfy Products to create: (i) any data, designs, or other materials subject to U.S. export control laws and regulations; (ii) any viruses, malware, ransomware, Trojan horses, worms, spyware, or other malicious or harmful code or content that could damage, disrupt, interfere with, or compromise the Comfy Products, Comfys systems or infrastructure, or the data or systems of any other user or third party; (iii) any Customer Data that depicts, promotes, or facilitates illegal activity, including without limitation child sexual abuse material, non-consensual intimate imagery, or content that incites violence or hatred against any individual or group; (iv) any Customer Data that infringes or misappropriates the intellectual property rights, privacy rights, or publicity rights of any third party, including without limitation by submitting models, images, or other materials without the right to do so; (v) any content or information that is intentionally deceptive or misleading, including without limitation synthetic media designed to impersonate a real individual without their consent; or (vi) any Customer Data that could reasonably be expected to cause harm to any individual or group.'
},
'enterprise-msa.4-payment.label': {
en: 'PAYMENT',
'zh-CN': 'PAYMENT'
},
'enterprise-msa.4-payment.title': {
en: '4. Payment',
'zh-CN': '4. Payment'
},
'enterprise-msa.4-payment.block.0': {
en: '<strong>Fees.</strong> Customer will pay Comfy the fees set forth in the applicable Order Form. Customer shall pay those amounts due and not disputed in good faith within seven (7) days of the date of receipt of the applicable invoice, unless a specific date for payment is set forth in such Order Form, in which case payment will be due on the date specified. Except as otherwise specified herein or in any applicable Order Form, (a) fees are quoted and payable in United States dollars and (b) payment obligations are non-cancelable and non-pro-ratable for partial months, and fees paid are non-refundable. Comfy reserves the right to change its fees upon each renewal term. Customer is responsible for all usage under Customers account, including usage by Customers Users and under Customers credentials and API keys.',
'zh-CN':
'<strong>Fees.</strong> Customer will pay Comfy the fees set forth in the applicable Order Form. Customer shall pay those amounts due and not disputed in good faith within seven (7) days of the date of receipt of the applicable invoice, unless a specific date for payment is set forth in such Order Form, in which case payment will be due on the date specified. Except as otherwise specified herein or in any applicable Order Form, (a) fees are quoted and payable in United States dollars and (b) payment obligations are non-cancelable and non-pro-ratable for partial months, and fees paid are non-refundable. Comfy reserves the right to change its fees upon each renewal term. Customer is responsible for all usage under Customers account, including usage by Customers Users and under Customers credentials and API keys.'
},
'enterprise-msa.4-payment.block.1': {
en: '<strong>Prepaid Credits.</strong> Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfys pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customers account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.',
'zh-CN':
'<strong>Prepaid Credits.</strong> Customer may prepay for usage credits (“Credits”) which may be applied toward usage of the Comfy Products at the rates set forth on Comfys pricing page. Except for documented billing errors or similar service issues attributed to Comfy, all purchases of Credits are final and non-refundable, and Comfy will not issue refunds or credits for any unused, partially used, or remaining Credits under any circumstances, including upon termination or expiration of Customers account. Comfy reserves the right to modify the pricing or Credit redemption rates applicable to future Credit purchases upon reasonable notice, but any Credits purchased prior to such modification will be honored at the rates in effect at the time of purchase.'
},
'enterprise-msa.4-payment.block.2': {
en: '<strong>Taxes.</strong> Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfys net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law.',
'zh-CN':
'<strong>Taxes.</strong> Fees are exclusive of all taxes, duties, levies, and similar governmental assessments (including sales, use, VAT/GST, and withholding taxes), and Customer is responsible for all such amounts other than taxes based on Comfys net income; if withholding is required by law, Customer will gross up payments so Comfy receives the invoiced amount, unless prohibited by law.'
},
'enterprise-msa.4-payment.block.3': {
en: '<strong>Late Payments; Suspension.</strong> Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.',
'zh-CN':
'<strong>Late Payments; Suspension.</strong> Overdue undisputed amounts may accrue interest at the lesser of 1.5% per month or the maximum rate permitted by law, plus reasonable collection costs. Comfy may suspend or limit access to the Comfy Products (including throttling, disabling API keys, or downgrading to the Free Tier) for non-payment of undisputed amounts after providing commercially reasonable notice and an opportunity to cure, unless Comfy reasonably determines immediate suspension is necessary to protect the Comfy Products or comply with Applicable Laws.'
},
'enterprise-msa.5-term-termination.label': {
en: 'TERM',
'zh-CN': 'TERM'
},
'enterprise-msa.5-term-termination.title': {
en: '5. Term; Termination',
'zh-CN': '5. Term; Termination'
},
'enterprise-msa.5-term-termination.block.0': {
en: '<strong>Term.</strong> The term of this Agreement will commence on the Effective Date and continue until terminated as set forth below (“Term”). The initial term of each Order Form will begin on the Subscription Start Date of such Order Form and will continue for the subscription term set forth therein. Except as set forth in such Order Form, the Order Form will renew for successive renewal terms equal to the length of the Initial Subscription Term.',
'zh-CN':
'<strong>Term.</strong> The term of this Agreement will commence on the Effective Date and continue until terminated as set forth below (“Term”). The initial term of each Order Form will begin on the Subscription Start Date of such Order Form and will continue for the subscription term set forth therein. Except as set forth in such Order Form, the Order Form will renew for successive renewal terms equal to the length of the Initial Subscription Term.'
},
'enterprise-msa.5-term-termination.block.1': {
en: '<strong>Termination of Agreement.</strong> Each party may terminate this Agreement upon written notice to the other party if there are no Order Forms then in effect. Each party may also terminate this Agreement or the applicable Order Form upon written notice in the event (a) the other party commits any material breach of this Agreement or the applicable Order Form and fails to remedy such breach within thirty (30) days after written notice of such breach or (b) subject to applicable law, upon the other partys liquidation, commencement of dissolution proceedings or assignment of substantially all its assets for the benefit of creditors, or if the other party becomes the subject of bankruptcy or similar proceeding that is not dismissed within sixty (60) days.',
'zh-CN':
'<strong>Termination of Agreement.</strong> Each party may terminate this Agreement upon written notice to the other party if there are no Order Forms then in effect. Each party may also terminate this Agreement or the applicable Order Form upon written notice in the event (a) the other party commits any material breach of this Agreement or the applicable Order Form and fails to remedy such breach within thirty (30) days after written notice of such breach or (b) subject to applicable law, upon the other partys liquidation, commencement of dissolution proceedings or assignment of substantially all its assets for the benefit of creditors, or if the other party becomes the subject of bankruptcy or similar proceeding that is not dismissed within sixty (60) days.'
},
'enterprise-msa.5-term-termination.block.2': {
en: '<strong>Deletion of Customer Data Upon Termination.</strong> Upon expiration or termination of this Agreement, Comfy will delete Customer Data from its primary production systems within sixty (60) days. Notwithstanding the foregoing, Customer Data may persist in routine backup systems beyond such period solely to the extent necessary under Comfys standard backup retention schedule, provided that such data is not actively accessed or used by Comfy and remains subject to the confidentiality obligations of this Agreement.',
'zh-CN':
'<strong>Deletion of Customer Data Upon Termination.</strong> Upon expiration or termination of this Agreement, Comfy will delete Customer Data from its primary production systems within sixty (60) days. Notwithstanding the foregoing, Customer Data may persist in routine backup systems beyond such period solely to the extent necessary under Comfys standard backup retention schedule, provided that such data is not actively accessed or used by Comfy and remains subject to the confidentiality obligations of this Agreement.'
},
'enterprise-msa.5-term-termination.block.3': {
en: '<strong>Survival.</strong> Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.',
'zh-CN':
'<strong>Survival.</strong> Termination or expiration will not affect any rights or obligations, including the payment of amounts due, which have accrued under this Agreement up to the date of termination or expiration. Upon termination or expiration of this Agreement, the provisions that are intended by their nature to survive termination will survive and continue in full force and effect in accordance with their terms, including confidentiality obligations, proprietary rights, indemnification, limitations of liability, and disclaimers.'
},
'enterprise-msa.6-confidentiality.label': {
en: 'CONFIDENTIALITY',
'zh-CN': 'CONFIDENTIALITY'
},
'enterprise-msa.6-confidentiality.title': {
en: '6. Confidentiality',
'zh-CN': '6. Confidentiality'
},
'enterprise-msa.6-confidentiality.block.0': {
en: '<strong>Definition of Confidential Information.</strong> “Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each partys Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Partys Confidential Information.',
'zh-CN':
'<strong>Definition of Confidential Information.</strong> “Confidential Information” means all non-public information disclosed by a party (“Disclosing Party”) to the other party (“Receiving Party”), whether oral or written, that is designated as confidential or that reasonably should be understood to be confidential given the nature of the information and circumstances of disclosure. Confidential Information of Customer includes Customer Data; Confidential Information of Comfy includes the Comfy Products; and each partys Confidential Information includes the terms of this Agreement and any Order Forms (including pricing), as well as business, financial, marketing, technical, and product information. Confidential Information excludes information that the Receiving Party can demonstrate: (i) is or becomes publicly available without breach; (ii) was known prior to disclosure without breach; (iii) is received from a third party without breach; or (iv) was independently developed without use of or reference to the Disclosing Partys Confidential Information.'
},
'enterprise-msa.6-confidentiality.block.1': {
en: '<strong>Protection of Confidential Information.</strong> The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.',
'zh-CN':
'<strong>Protection of Confidential Information.</strong> The Receiving Party will: (a) protect Confidential Information using at least reasonable care; (b) use it solely to perform under this Agreement; and (c) limit access to its and its Affiliates employees and contractors with a need to know and confidentiality obligations at least as protective as those herein. Neither party may disclose the terms of this Agreement or any Order Form except to its Affiliates, legal counsel, or accountants, and remains responsible for their compliance. Upon written request, the Receiving Party will promptly return or destroy Confidential Information, except for information retained in routine backups or as required by law or internal retention policies.'
},
'enterprise-msa.6-confidentiality.block.2': {
en: '<strong>Compelled Disclosure.</strong> The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Partys expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Partys possession.',
'zh-CN':
'<strong>Compelled Disclosure.</strong> The Receiving Party may disclose Confidential Information if legally required, provided it gives prior notice (where permitted) and reasonable assistance, at the Disclosing Partys expense, to seek protective treatment. Any disclosure will be limited to what is legally required, and the Receiving Party will request confidential treatment. These obligations survive while Confidential Information remains in the Receiving Partys possession.'
},
'enterprise-msa.6-confidentiality.block.3': {
en: '<strong>Data Security.</strong> Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.',
'zh-CN':
'<strong>Data Security.</strong> Comfy will implement and maintain commercially reasonable administrative, technical, and physical safeguards designed to protect Customer Data against unauthorized access, disclosure, alteration, or destruction. These measures will be no less protective than those Comfy uses to protect its own confidential information of a similar nature. In the event Comfy becomes aware of a confirmed security breach that results in unauthorized access to or disclosure of Customer Data, Comfy will notify Customer without undue delay and will provide reasonable cooperation to assist Customer in investigating and mitigating the effects of such breach. Customer acknowledges that no security measures are perfect or impenetrable, and Comfy does not guarantee that Customer Data will be free from unauthorized access or disclosure.'
},
'enterprise-msa.7-proprietary-rights.label': {
en: 'IP',
'zh-CN': 'IP'
},
'enterprise-msa.7-proprietary-rights.title': {
en: '7. Proprietary Rights',
'zh-CN': '7. Proprietary Rights'
},
'enterprise-msa.7-proprietary-rights.block.0': {
en: '<strong>Reservation of Rights.</strong> Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.',
'zh-CN':
'<strong>Reservation of Rights.</strong> Comfy and its licensors retain all right, title, and interest, including all intellectual property and proprietary rights, in and to the Comfy Products, Comfy Branding, and all software, code, algorithms, protocols, interfaces, tools, documentation, data structures, and other technology underlying or embodied in, or used to provide, the Comfy Products (collectively, “Comfy Materials”). Except for the limited rights expressly granted to Customer under this Agreement, no rights or licenses are granted, whether by implication, estoppel, or otherwise. Comfy expressly reserves all rights in and to the Comfy Materials not expressly granted hereunder.'
},
'enterprise-msa.7-proprietary-rights.block.1': {
en: '<strong>Feedback.</strong> Customer may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding Customers experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and Customer hereby grants Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfys products and services.',
'zh-CN':
'<strong>Feedback.</strong> Customer may from time to time provide feedback (including suggestions, comments for enhancements, functionality or usability, etc.) (“Feedback”) to Comfy regarding Customers experience using, and needs and integration requirements for, the Comfy Products. Comfy shall have full discretion to determine whether or not to proceed with the development of any requested enhancements, new features or functionality, and Customer hereby grants Comfy the full, unencumbered, royalty-free right to incorporate and otherwise fully exploit Feedback in connection with Comfys products and services.'
},
'enterprise-msa.7-proprietary-rights.block.2': {
en: '<strong>Operational Metadata.</strong> Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.',
'zh-CN':
'<strong>Operational Metadata.</strong> Customer agrees that Comfy may collect and use Operational Metadata to operate, maintain, improve, and support the Comfy Products, including for diagnostics, analytics, system performance, and reporting purposes. Comfy will only disclose Operational Metadata externally if such data is (a) aggregated or anonymized with data across other customers, and (b) does not disclose the identity of Customer or any Customer Confidential Information.'
},
'enterprise-msa.8-warranties-disclaimer.label': {
en: 'WARRANTIES',
'zh-CN': 'WARRANTIES'
},
'enterprise-msa.8-warranties-disclaimer.title': {
en: '8. Warranties; Disclaimer',
'zh-CN': '8. Warranties; Disclaimer'
},
'enterprise-msa.8-warranties-disclaimer.block.0': {
en: '<strong>Comfy.</strong> Comfy warrants that it will, consistent with prevailing industry standards, provide the Comfy Products in a professional and workmanlike manner and the Comfy Products will conform in all material respects with the Documentation. For material breach of the foregoing express warranty, Customers exclusive remedy shall be the re-performance of the deficient Comfy Products or, if Comfy cannot re-perform such deficient Comfy Products as warranted within thirty (30) days after receipt of written notice of the warranty breach, Customer shall be entitled to terminate the applicable Order Form and recover a pro-rata portion of the prepaid subscription fees corresponding to the terminated portion of the applicable subscription term.',
'zh-CN':
'<strong>Comfy.</strong> Comfy warrants that it will, consistent with prevailing industry standards, provide the Comfy Products in a professional and workmanlike manner and the Comfy Products will conform in all material respects with the Documentation. For material breach of the foregoing express warranty, Customers exclusive remedy shall be the re-performance of the deficient Comfy Products or, if Comfy cannot re-perform such deficient Comfy Products as warranted within thirty (30) days after receipt of written notice of the warranty breach, Customer shall be entitled to terminate the applicable Order Form and recover a pro-rata portion of the prepaid subscription fees corresponding to the terminated portion of the applicable subscription term.'
},
'enterprise-msa.8-warranties-disclaimer.block.1': {
en: '<strong>Customer.</strong> Customer represents and warrants that it owns or has obtained all necessary rights, licenses, and permissions to submit Customer Data to the Comfy Products, and that Customer Data does not include any content that Customer is legally prohibited from sharing or processing through the Comfy Products.',
'zh-CN':
'<strong>Customer.</strong> Customer represents and warrants that it owns or has obtained all necessary rights, licenses, and permissions to submit Customer Data to the Comfy Products, and that Customer Data does not include any content that Customer is legally prohibited from sharing or processing through the Comfy Products.'
},
'enterprise-msa.8-warranties-disclaimer.block.2': {
en: '<strong>Disclaimer.</strong> EXCEPT AS SET FORTH HEREIN, THE COMFY PRODUCTS AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. COMFY DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE COMFY PRODUCTS (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. CUSTOMER AGREES AND ACKNOWLEDGES THAT CUSTOMERS USE OF ANY OUTPUT PROVIDED BY THE COMFY PRODUCTS IS AT CUSTOMERS OWN RISK. Customer is solely responsible for (a) verifying the Output is appropriate for Customers use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. IN NO EVENT WILL COMFY BE LIABLE FOR ANY DAMAGES OR LOSSES ARISING FROM OR RELATED TO CUSTOMERS USE OF OR RELIANCE ON THE OUTPUT, INCLUDING ANY DECISIONS MADE OR ACTIONS TAKEN BASED ON THE OUTPUT.',
'zh-CN':
'<strong>Disclaimer.</strong> EXCEPT AS SET FORTH HEREIN, THE COMFY PRODUCTS AND OUTPUT ARE PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND. COMFY DISCLAIMS ANY AND ALL WARRANTIES, REPRESENTATIONS, AND CONDITIONS RELATING TO THE COMFY PRODUCTS (INCLUDING ANY OUTPUT), WHETHER EXPRESS, IMPLIED, INCLUDING, BUT NOT LIMITED TO, ANY REPRESENTATION, WARRANTY, OR CONDITION OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE OR NON-INFRINGEMENT. CUSTOMER AGREES AND ACKNOWLEDGES THAT CUSTOMERS USE OF ANY OUTPUT PROVIDED BY THE COMFY PRODUCTS IS AT CUSTOMERS OWN RISK. Customer is solely responsible for (a) verifying the Output is appropriate for Customers use case, and (b) any decisions, actions, or omissions taken in reliance on the OUTPUT. IN NO EVENT WILL COMFY BE LIABLE FOR ANY DAMAGES OR LOSSES ARISING FROM OR RELATED TO CUSTOMERS USE OF OR RELIANCE ON THE OUTPUT, INCLUDING ANY DECISIONS MADE OR ACTIONS TAKEN BASED ON THE OUTPUT.'
},
'enterprise-msa.9-limitation-of-liability.label': {
en: 'LIABILITY',
'zh-CN': 'LIABILITY'
},
'enterprise-msa.9-limitation-of-liability.title': {
en: '9. Limitation of Liability',
'zh-CN': '9. Limitation of Liability'
},
'enterprise-msa.9-limitation-of-liability.block.0': {
en: 'UNDER NO LEGAL THEORY, WHETHER IN TORT, CONTRACT, OR OTHERWISE, WILL EITHER PARTY BE LIABLE TO THE OTHER UNDER THIS AGREEMENT FOR (A) ANY INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL OR PUNITIVE DAMAGES OF ANY CHARACTER, INCLUDING DAMAGES FOR LOSS OF GOODWILL, LOST PROFITS, LOST SALES OR BUSINESS, WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, LOST CONTENT OR DATA, EVEN IF A REPRESENTATIVE OF SUCH PARTY HAS BEEN ADVISED, KNEW OR SHOULD HAVE KNOWN OF THE POSSIBILITY OF SUCH DAMAGES, OR (B) EXCLUDING CUSTOMERS PAYMENT OBLIGATIONS, ANY AGGREGATE DAMAGES, COSTS, OR LIABILITIES IN EXCESS OF THE AMOUNTS PAID BY CUSTOMER UNDER THE APPLICABLE ORDER FORM DURING THE TWELVE (12) MONTHS PRECEDING THE CLAIM.',
'zh-CN':
'UNDER NO LEGAL THEORY, WHETHER IN TORT, CONTRACT, OR OTHERWISE, WILL EITHER PARTY BE LIABLE TO THE OTHER UNDER THIS AGREEMENT FOR (A) ANY INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL OR PUNITIVE DAMAGES OF ANY CHARACTER, INCLUDING DAMAGES FOR LOSS OF GOODWILL, LOST PROFITS, LOST SALES OR BUSINESS, WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, LOST CONTENT OR DATA, EVEN IF A REPRESENTATIVE OF SUCH PARTY HAS BEEN ADVISED, KNEW OR SHOULD HAVE KNOWN OF THE POSSIBILITY OF SUCH DAMAGES, OR (B) EXCLUDING CUSTOMERS PAYMENT OBLIGATIONS, ANY AGGREGATE DAMAGES, COSTS, OR LIABILITIES IN EXCESS OF THE AMOUNTS PAID BY CUSTOMER UNDER THE APPLICABLE ORDER FORM DURING THE TWELVE (12) MONTHS PRECEDING THE CLAIM.'
},
'enterprise-msa.10-indemnification.label': {
en: 'INDEMNITY',
'zh-CN': 'INDEMNITY'
},
'enterprise-msa.10-indemnification.title': {
en: '10. Indemnification',
'zh-CN': '10. Indemnification'
},
'enterprise-msa.10-indemnification.block.0': {
en: '<strong>Indemnity by Comfy.</strong> Comfy will defend Customer against any claim, demand, suit, or proceeding (“Claim”) made or brought against Customer by a third party alleging that the Comfy Products as provided by Comfy infringes or misappropriates a U.S. patent, copyright or trade secret and will indemnify Customer for any damages finally awarded against Customer (or any settlement approved by Comfy) in connection with any such Claim; provided that (a) Customer will promptly notify Comfy of such Claim, (b) Comfy will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Comfy may not settle any Claim without Customers prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Customer of all related liability) and (c) Customer reasonably cooperates with Comfy in connection therewith. If the use of the Comfy Products by Customer has become, or in Comfys opinion is likely to become, the subject of any claim of infringement, Comfy may at its option and expense (i) procure for Customer the right to continue using and receiving the Comfy Products as set forth hereunder; (ii) replace or modify the Comfy Products to make it non-infringing (with comparable functionality); or (iii) if the options in clauses (i) or (ii) are not reasonably practicable, terminate the applicable Order Form and provide a pro rata refund of any prepaid subscription fees corresponding to the terminated portion of the applicable subscription term. Comfy will have no liability or obligation with respect to any Claim to the extent such Claim is caused by (A) prompts, inputs, or other instructions or materials submitted by Customer or its Users; (B) Customers use of any outputs, generated content, or models in a manner not authorized under this Agreement; (C) modification of any generated outputs by or on behalf of Customer; (D) Customer Data, including any third-party intellectual property, likenesses, or other proprietary material incorporated therein; or (E) Customers failure to obtain rights, consents, or clearances required for the submission or use of any content through the Comfy Products (clauses (A) through (E), “Excluded Claims”). This Section states Comfys sole and exclusive liability and obligation, and Customers exclusive remedy, for any claim of any nature related to infringement or misappropriation of intellectual property.',
'zh-CN':
'<strong>Indemnity by Comfy.</strong> Comfy will defend Customer against any claim, demand, suit, or proceeding (“Claim”) made or brought against Customer by a third party alleging that the Comfy Products as provided by Comfy infringes or misappropriates a U.S. patent, copyright or trade secret and will indemnify Customer for any damages finally awarded against Customer (or any settlement approved by Comfy) in connection with any such Claim; provided that (a) Customer will promptly notify Comfy of such Claim, (b) Comfy will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Comfy may not settle any Claim without Customers prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Customer of all related liability) and (c) Customer reasonably cooperates with Comfy in connection therewith. If the use of the Comfy Products by Customer has become, or in Comfys opinion is likely to become, the subject of any claim of infringement, Comfy may at its option and expense (i) procure for Customer the right to continue using and receiving the Comfy Products as set forth hereunder; (ii) replace or modify the Comfy Products to make it non-infringing (with comparable functionality); or (iii) if the options in clauses (i) or (ii) are not reasonably practicable, terminate the applicable Order Form and provide a pro rata refund of any prepaid subscription fees corresponding to the terminated portion of the applicable subscription term. Comfy will have no liability or obligation with respect to any Claim to the extent such Claim is caused by (A) prompts, inputs, or other instructions or materials submitted by Customer or its Users; (B) Customers use of any outputs, generated content, or models in a manner not authorized under this Agreement; (C) modification of any generated outputs by or on behalf of Customer; (D) Customer Data, including any third-party intellectual property, likenesses, or other proprietary material incorporated therein; or (E) Customers failure to obtain rights, consents, or clearances required for the submission or use of any content through the Comfy Products (clauses (A) through (E), “Excluded Claims”). This Section states Comfys sole and exclusive liability and obligation, and Customers exclusive remedy, for any claim of any nature related to infringement or misappropriation of intellectual property.'
},
'enterprise-msa.10-indemnification.block.1': {
en: '<strong>Indemnification by Customer.</strong> Customer will defend Comfy against any Claim made or brought against Comfy by a third party to the extent arising out of Customers breach of Section 3 or the Excluded Claims, and Customer will indemnify Comfy for any damages finally awarded against Comfy (or any settlement approved by Customer) in connection with any such Claim; provided that (a) Comfy will promptly notify Customer of such Claim, (b) Customer will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Customer may not settle any Claim without Comfys prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Comfy of all liability) and (c) Comfy reasonably cooperates with Customer in connection therewith.',
'zh-CN':
'<strong>Indemnification by Customer.</strong> Customer will defend Comfy against any Claim made or brought against Comfy by a third party to the extent arising out of Customers breach of Section 3 or the Excluded Claims, and Customer will indemnify Comfy for any damages finally awarded against Comfy (or any settlement approved by Customer) in connection with any such Claim; provided that (a) Comfy will promptly notify Customer of such Claim, (b) Customer will have the sole and exclusive authority to defend and/or settle any such Claim (provided that Customer may not settle any Claim without Comfys prior written consent, which will not be unreasonably withheld, unless it unconditionally releases Comfy of all liability) and (c) Comfy reasonably cooperates with Customer in connection therewith.'
},
'enterprise-msa.11-miscellaneous.label': {
en: 'MISCELLANEOUS',
'zh-CN': 'MISCELLANEOUS'
},
'enterprise-msa.11-miscellaneous.title': {
en: '11. Miscellaneous',
'zh-CN': '11. Miscellaneous'
},
'enterprise-msa.11-miscellaneous.block.0': {
en: '<strong>Governing Law.</strong> This Agreement will be governed by the laws of the State of California, exclusive of its rules governing choice of law and conflict of laws. The parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum. This Agreement will not be governed by the United Nations Convention on Contracts for the International Sale of Goods.',
'zh-CN':
'<strong>Governing Law.</strong> This Agreement will be governed by the laws of the State of California, exclusive of its rules governing choice of law and conflict of laws. The parties agree to the exclusive jurisdiction and venue of the state and federal courts located in San Francisco, CA and each party irrevocably submits to such jurisdiction and venue and waives any objection based on inconvenient forum. This Agreement will not be governed by the United Nations Convention on Contracts for the International Sale of Goods.'
},
'enterprise-msa.11-miscellaneous.block.1': {
en: '<strong>Export Compliance.</strong> Customer will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in using the Comfy Products.',
'zh-CN':
'<strong>Export Compliance.</strong> Customer will comply with the export laws and regulations of the United States, the European Union and other applicable jurisdictions in using the Comfy Products.'
},
'enterprise-msa.11-miscellaneous.block.2': {
en: '<strong>Publicity.</strong> Customer agrees that Comfy may refer to Customers name, logo, and trademarks in Comfys marketing materials and website; however, Comfy will not use Customers name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without Customers prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.',
'zh-CN':
'<strong>Publicity.</strong> Customer agrees that Comfy may refer to Customers name, logo, and trademarks in Comfys marketing materials and website; however, Comfy will not use Customers name or trademarks in any other publicity (e.g., press releases, customer references and case studies) without Customers prior written consent (which may be by email) not to be unreasonably withheld, conditioned, or delayed.'
},
'enterprise-msa.11-miscellaneous.block.3': {
en: '<strong>Third-Party Infrastructure.</strong> Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfys control.',
'zh-CN':
'<strong>Third-Party Infrastructure.</strong> Customer acknowledges that the Comfy Products relies on third-party infrastructure, hardware, and services, including cloud computing providers and GPU infrastructure providers (collectively, “Third-Party Infrastructure”), and that the availability, performance, and security of the Comfy Products may be affected by the operation, maintenance, or failure of such Third-Party Infrastructure. Comfy will use commercially reasonable efforts to maintain Comfy Products availability but makes no representation or warranty regarding the performance or availability of any Third-Party Infrastructure, and Comfy shall have no liability to Customer for any interruption, degradation, loss of data, or other harm arising out of or related to any failure, outage, or limitation of Third-Party Infrastructure, whether or not within Comfys control.'
},
'enterprise-msa.11-miscellaneous.block.4': {
en: '<strong>Assignment; Delegation.</strong> Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other partys prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.',
'zh-CN':
'<strong>Assignment; Delegation.</strong> Neither party hereto may assign or otherwise transfer this Agreement, in whole or in part, without the other partys prior written consent, except that Comfy may assign this Agreement without consent to a successor to all or substantially all of its assets or business related to this Agreement. Any attempted assignment, delegation, or transfer by either party in violation hereof will be null and void. Subject to the foregoing, this Agreement will be binding on the parties and their successors and assigns.'
},
'enterprise-msa.11-miscellaneous.block.5': {
en: '<strong>Amendment; Waiver.</strong> No amendment or modification to this Agreement, nor any waiver of any rights hereunder, will be effective unless assented to in writing by both parties. Any such waiver will be only to the specific provision and under the specific circumstances for which it was given and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.',
'zh-CN':
'<strong>Amendment; Waiver.</strong> No amendment or modification to this Agreement, nor any waiver of any rights hereunder, will be effective unless assented to in writing by both parties. Any such waiver will be only to the specific provision and under the specific circumstances for which it was given and will not apply with respect to any repeated or continued violation of the same provision or any other provision. Failure or delay by either party to enforce any provision of this Agreement will not be deemed a waiver of future enforcement of that or any other provision.'
},
'enterprise-msa.11-miscellaneous.block.6': {
en: '<strong>Relationship.</strong> Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.',
'zh-CN':
'<strong>Relationship.</strong> Nothing contained herein will in any way constitute any association, partnership, agency, employment or joint venture between the parties hereto, or be construed to evidence the intention of the parties to establish any such relationship. Neither party will have the authority to obligate or bind the other in any manner, and nothing herein contained will give rise to, or is intended to give rise to any rights of any kind in favor of any third parties.'
},
'enterprise-msa.11-miscellaneous.block.7': {
en: '<strong>Unenforceability.</strong> If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.',
'zh-CN':
'<strong>Unenforceability.</strong> If a court of competent jurisdiction determines that any provision of this Agreement is invalid, illegal, or otherwise unenforceable, such provision will be enforced as nearly as possible in accordance with the stated intention of the parties, while the remainder of this Agreement will remain in full force and effect and bind the parties according to its terms.'
},
'enterprise-msa.11-miscellaneous.block.8': {
en: '<strong>Notices.</strong> Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to the parties must be sent to the respective address set forth in the signature blocks below, or such other address designated pursuant to this Section.',
'zh-CN':
'<strong>Notices.</strong> Any notice required or permitted to be given hereunder will be given in writing by personal delivery, certified mail, return receipt requested, or by overnight delivery. Notices to the parties must be sent to the respective address set forth in the signature blocks below, or such other address designated pursuant to this Section.'
},
'enterprise-msa.11-miscellaneous.block.9': {
en: '<strong>Force Majeure.</strong> Neither party will be deemed in breach hereunder for any cessation, interruption or delay in the performance of its obligations due to causes beyond its reasonable control, including earthquake, flood, or other natural disaster, act of God, labor controversy, civil disturbance, terrorism, war (whether or not officially declared), cyber attacks (e.g., denial of service attacks), or the inability to obtain sufficient supplies, transportation, or other essential commodity or service required in the conduct of its business, or any change in or the adoption of any law, regulation, judgment or decree for which the party could not reasonably prepare mitigation in advance.',
'zh-CN':
'<strong>Force Majeure.</strong> Neither party will be deemed in breach hereunder for any cessation, interruption or delay in the performance of its obligations due to causes beyond its reasonable control, including earthquake, flood, or other natural disaster, act of God, labor controversy, civil disturbance, terrorism, war (whether or not officially declared), cyber attacks (e.g., denial of service attacks), or the inability to obtain sufficient supplies, transportation, or other essential commodity or service required in the conduct of its business, or any change in or the adoption of any law, regulation, judgment or decree for which the party could not reasonably prepare mitigation in advance.'
},
'enterprise-msa.11-miscellaneous.block.10': {
en: '<strong>Entire Agreement.</strong> This Agreement comprises the entire agreement between Customer and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.',
'zh-CN':
'<strong>Entire Agreement.</strong> This Agreement comprises the entire agreement between Customer and Comfy with respect to its subject matter, and supersedes all prior and contemporaneous proposals, statements, sales materials or presentations and agreements (oral and written). No oral or written information or advice given by Comfy, its agents or employees will create a warranty or in any way increase the scope of the warranties in this Agreement.'
},
'enterprise-msa.12-exhibit-a.label': {
en: 'EXHIBIT A',
'zh-CN': 'EXHIBIT A'
},
'enterprise-msa.12-exhibit-a.title': {
en: 'Exhibit A. Order Form',
'zh-CN': 'Exhibit A. Order Form'
},
'enterprise-msa.12-exhibit-a.block.0': {
en: 'The initial Order Form is attached as <strong>Exhibit A</strong> to the executed copy of this Agreement. Each Order Form is subject to the terms and conditions of this Agreement, and by executing an Order Form, Customer agrees to be bound by the terms and conditions of this Agreement.',
'zh-CN':
'The initial Order Form is attached as <strong>Exhibit A</strong> to the executed copy of this Agreement. Each Order Form is subject to the terms and conditions of this Agreement, and by executing an Order Form, Customer agrees to be bound by the terms and conditions of this Agreement.'
},
'enterprise-msa.12-exhibit-a.block.1': {
en: 'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.',
'zh-CN':
'This document reproduces the current template of the Enterprise Customer Agreement for reference only. The executed Agreement between Comfy and Customer, together with any signed Order Forms, governs the relationship between the parties. To request an executable copy, please contact <a href="mailto:sales@comfy.org" class="text-white underline">sales@comfy.org</a>.'
},
'enterprise-msa.page.title': {
en: 'Enterprise MSA — Comfy',
'zh-CN': 'Enterprise MSA — Comfy'
},
'enterprise-msa.page.description': {
en: 'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.',
'zh-CN':
'Comfy Enterprise Customer Agreement — the master services agreement that governs Comfy Enterprise deployments of Comfy Cloud, Comfy API, and related products.'
},
'enterprise-msa.page.heading': {
en: 'Enterprise Customer Agreement',
'zh-CN': 'Enterprise Customer Agreement'
},
'enterprise-msa.page.tocLabel': {
en: 'On this page',
'zh-CN': 'On this page'
},
'enterprise-msa.page.effectiveDateLabel': {
en: 'Effective Date',
'zh-CN': 'Effective Date'
},
'enterprise-msa.page.parties': {
en: 'This Enterprise Customer Agreement (the “Agreement”) is entered into by and between Comfy Organization, Inc., a Delaware corporation (“Comfy”), and the entity identified on the applicable Order Form (“Customer”), and is effective as of the date set forth on the applicable Order Form (the “Effective Date”).',
'zh-CN':
'This Enterprise Customer Agreement (the “Agreement”) is entered into by and between Comfy Organization, Inc., a Delaware corporation (“Comfy”), and the entity identified on the applicable Order Form (“Customer”), and is effective as of the date set forth on the applicable Order Form (the “Effective Date”).'
},
'footer.enterpriseMsa': {
en: 'Enterprise MSA',
'zh-CN': 'Enterprise MSA'
},
// Customers page
'customers.hero.label': {
en: 'CUSTOMER STORIES',
@@ -4416,12 +3983,12 @@ const translations = {
// Launches page (/launches) — subscribe banner
// zh-CN strings pending native review (see apps/website/.scratch/drops-page/PRD.md)
'launches.banner.text': {
en: 'Now turn your agent into a creative technologist.',
'zh-CN': '现在,让你的智能体成为创意技术专家。'
en: 'Join the live stream. Get answers in real time.',
'zh-CN': '加入直播,实时获得解答。'
},
'launches.banner.cta': {
en: 'Start Comfy MCP',
'zh-CN': '启动 Comfy MCP'
en: 'Join livestream',
'zh-CN': '加入直播'
},
// Launches page (/launches) — closing CTA

View File

@@ -5,15 +5,6 @@ import '../styles/global.css'
import type { Locale } from '../i18n/translations'
import SiteFooter from '../components/common/SiteFooter.vue'
import HeaderMain from '../components/common/HeaderMain/HeaderMain.vue'
import AnnouncementBanner from '../templates/drops/AnnouncementBanner.vue'
import { bannerConfig, getBannerData } from '../config/banner'
import { isHrefActive } from '../composables/useCurrentPath'
import {
BANNER_DISMISS_ATTR,
BANNER_STORAGE_KEY,
createBannerVersion,
evaluateBannerVisibility
} from '../utils/banner'
import { escapeJsonLd } from '../utils/escapeJsonLd'
import { fetchGitHubStars, formatStarCount } from '../utils/github'
@@ -43,18 +34,6 @@ const locale: Locale = rawLocale === 'zh-CN' ? 'zh-CN' : 'en'
const rawStars = await fetchGitHubStars('Comfy-Org', 'ComfyUI')
const githubStars = rawStars ? formatStarCount(rawStars) : ''
// Announcement banner — build-time visibility gate + content-hash version key.
// A promo never advertises the page you are already on, so the banner is
// suppressed when its CTA points at the current path.
const bannerData = getBannerData(bannerConfig, locale)
const bannerVisible =
evaluateBannerVisibility(bannerConfig, {
currentLocale: locale,
currentSection: 'sitewide',
now: new Date(),
}) && !isHrefActive(bannerData.link?.href ?? '', Astro.url.pathname)
const bannerVersion = createBannerVersion(bannerData, locale)
const gtmId = 'GTM-NP9JM6K7'
const gtmEnabled = import.meta.env.PROD
@@ -145,25 +124,6 @@ const websiteJsonLd = {
<ClientRouter />
<slot name="head" />
<!-- Hide an already-dismissed announcement banner before first paint (no flash/shift). -->
{bannerVisible && (
<script
is:inline
define:vars={{
bannerVersion,
storageKey: BANNER_STORAGE_KEY,
dismissAttr: BANNER_DISMISS_ATTR
}}
>
try {
const dismissed = JSON.parse(localStorage.getItem(storageKey) || '{}')
if (dismissed[bannerVersion]) {
document.documentElement.setAttribute(dismissAttr, '')
}
} catch (e) {}
</script>
)}
</head>
<body class="bg-primary-comfy-ink text-white font-formula antialiased overflow-x-clip">
{gtmEnabled && (
@@ -177,16 +137,8 @@ const websiteJsonLd = {
</noscript>
)}
{bannerVisible && (
<AnnouncementBanner
data={bannerData}
version={bannerVersion}
locale={locale}
client:load
/>
)}
<HeaderMain locale={locale} github-stars={githubStars} client:load />
<main>
<main class="mt-20 lg:mt-32">
<slot />
</main>
<SiteFooter locale={locale} client:load />

View File

@@ -1,36 +0,0 @@
---
// Enterprise Customer Agreement (Enterprise MSA) — English only, by design.
// Legal-reviewed copy must not be served under a localized route until legal
// explicitly approves a translation; rendering an unreviewed translation as
// the active MSA exposes us to liability from the translation diverging from
// the approved English source. See the matching comment in
// src/i18n/translations.ts for the i18n block, and the entry in
// LOCALE_INVARIANT_ROUTE_KEYS in src/config/routes.ts.
import BaseLayout from '../layouts/BaseLayout.astro'
import HeroSection from '../components/legal/HeroSection.vue'
import LegalContentSection from '../components/legal/LegalContentSection.vue'
import { t } from '../i18n/translations'
---
<BaseLayout
title={t('enterprise-msa.page.title')}
description={t('enterprise-msa.page.description')}
>
<HeroSection title={t('enterprise-msa.page.heading')} />
<p class="text-primary-warm-gray mt-2 text-center text-sm">
{t('enterprise-msa.page.effectiveDateLabel')}: {
t('enterprise-msa.effective-date')
}
</p>
<p
class="text-primary-comfy-canvas mx-auto mt-8 max-w-3xl px-4 text-center text-sm/relaxed lg:px-0"
>
{t('enterprise-msa.page.parties')}
</p>
<LegalContentSection
prefix="enterprise-msa"
locale="en"
tocLabelKey="enterprise-msa.page.tocLabel"
client:load
/>
</BaseLayout>

View File

@@ -3,6 +3,7 @@ import BaseLayout from '../layouts/BaseLayout.astro'
import CtaSection from '../templates/drops/CtaSection.vue'
import DropsSection from '../templates/drops/DropsSection.vue'
import HeroSection from '../templates/drops/HeroSection.vue'
import SubscribeBanner from '../templates/drops/SubscribeBanner.vue'
import { t } from '../i18n/translations'
const locale = 'en' as const
@@ -12,6 +13,7 @@ const locale = 'en' as const
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />

View File

@@ -3,6 +3,7 @@ import BaseLayout from '../../layouts/BaseLayout.astro'
import CtaSection from '../../templates/drops/CtaSection.vue'
import DropsSection from '../../templates/drops/DropsSection.vue'
import HeroSection from '../../templates/drops/HeroSection.vue'
import SubscribeBanner from '../../templates/drops/SubscribeBanner.vue'
import { t } from '../../i18n/translations'
const locale = 'zh-CN' as const
@@ -12,6 +13,7 @@ const locale = 'zh-CN' as const
title={t('launches.page.title', locale)}
description={t('launches.page.description', locale)}
>
<SubscribeBanner locale={locale} client:load />
<HeroSection locale={locale} client:load />
<DropsSection locale={locale} />
<CtaSection locale={locale} />

View File

@@ -70,7 +70,6 @@
--color-secondary-mauve: #4d3762;
--color-destructive: #f44336;
--color-primary-comfy-plum: #49378b;
--color-secondary-deep-plum: #2b2040;
--color-secondary-cool-gray: #3c3c3c;
--color-illustration-forest: #20464c;
--color-transparency-white-t4: rgb(255 255 255 / 0.04);
@@ -94,14 +93,6 @@
initial-value: 0deg;
}
/* Pre-hydration hide for a dismissed announcement banner (set by an inline
script in BaseLayout head) — prevents any flash before Vue hydrates.
The [data-banner-dismissed] literal is BANNER_DISMISS_ATTR in utils/banner.ts;
keep them in sync. */
[data-banner-dismissed] [data-slot='announcement-banner'] {
display: none;
}
@keyframes border-angle-spin {
to {
--border-angle: 360deg;

View File

@@ -1,107 +0,0 @@
<script setup lang="ts">
import { ArrowRight, X } from '@lucide/vue'
import type { BannerData } from '../../config/banner'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Button from '@/components/ui/button/Button.vue'
import IconButton from '@/components/ui/icon-button/IconButton.vue'
import { useBannerDismissal } from '../../composables/useBannerDismissal'
const {
data,
version,
locale = 'en'
} = defineProps<{
data: BannerData
version: string
locale?: Locale
}>()
const { isVisible, close, persistHidden } = useBannerDismissal(version)
</script>
<template>
<Transition name="banner-collapse" @after-leave="persistHidden">
<div v-if="isVisible" class="banner-collapse grid">
<div class="min-h-0 overflow-hidden">
<div
data-slot="announcement-banner"
class="after:bg-transparency-white-t4 relative flex items-center gap-x-6 px-6 py-4 after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px sm:px-3.5 sm:before:flex-1"
style="
background: linear-gradient(
90deg,
var(--color-primary-comfy-plum) 0%,
var(--color-secondary-deep-plum) 53.85%,
var(--color-secondary-mauve) 100%
);
"
>
<div class="flex flex-wrap items-center gap-x-8 gap-y-2">
<p
class="text-primary-warm-white ppformula-text-center text-sm md:text-base/6"
>
{{ data.title }}
<span v-if="data.description" class="text-primary-warm-white/80">
{{ data.description }}
</span>
</p>
<Button
v-if="data.link"
as="a"
:href="data.link.href"
:target="data.link.target"
:rel="data.link.rel"
:variant="data.link.buttonVariant ?? 'underlineLink'"
size="sm"
>
{{ data.link.title }}
<template #append>
<ArrowRight class="size-4" />
</template>
</Button>
</div>
<div class="flex flex-1 justify-end">
<IconButton
type="button"
:aria-label="t('nav.close', locale)"
@click="close"
>
<X class="size-5" aria-hidden="true" />
</IconButton>
</div>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
/* Collapse the banner's height (grid 1fr → 0fr) so page content below slides
up smoothly, with a fade. Enter is defined for symmetry; in practice only the
leave (dismiss) runs, since the banner renders present in the static HTML. */
.banner-collapse {
grid-template-rows: 1fr;
}
.banner-collapse-enter-active,
.banner-collapse-leave-active {
transition:
grid-template-rows 300ms ease,
opacity 250ms ease;
}
.banner-collapse-enter-from,
.banner-collapse-leave-to {
grid-template-rows: 0fr;
opacity: 0;
}
@media (prefers-reduced-motion: reduce) {
.banner-collapse-enter-active,
.banner-collapse-leave-active {
transition: none;
}
}
</style>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { onMounted, ref } from 'vue'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import Button from '@/components/ui/button/Button.vue'
import { resolveRel } from '../../utils/cta'
import { livestream } from './livestream'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const signUpHref = `https://www.youtube.com/watch?v=${livestream.youtubeVideoId}`
const signUpRel = resolveRel({ target: '_blank' })
// Hide once the livestream window closes — both for visitors arriving after
// the event and for visitors whose tab is open when it ends.
const endMs = new Date(livestream.endDateTime).getTime()
const visible = ref(true)
// useTimeoutFn auto-clears on unmount. Arm it client-side only so SSR never
// schedules a long-lived server timer.
const { start } = useTimeoutFn(
() => {
visible.value = false
},
() => Math.max(0, endMs - Date.now()),
{ immediate: false }
)
onMounted(() => {
if (endMs - Date.now() <= 0) {
visible.value = false
} else {
start()
}
})
</script>
<template>
<div v-if="visible" class="px-4">
<div
class="bg-primary-comfy-plum max-w-8xl rounded-5xl text-primary-warm-white mx-auto flex w-full flex-col items-center justify-center gap-2 px-6 py-5 text-center text-sm sm:flex-row sm:gap-4"
>
<p class="ppformula-text-center">
{{ t('launches.banner.text', locale) }}
</p>
<Button
:href="signUpHref"
as="a"
variant="underlineLink"
size="sm"
target="_blank"
:rel="signUpRel"
>
{{ t('launches.banner.cta', locale) }}
</Button>
</div>
</div>
</template>

View File

@@ -17,7 +17,7 @@ const ctas = mcpCtas(locale)
badge-text="MCP"
:title="t('mcp.hero.heading', locale)"
:subtitle="t('mcp.hero.subtitle', locale)"
:primary-cta="ctas.installMcp"
:primary-cta="ctas.runWorkflow"
:secondary-cta="ctas.docs"
>
<template #media>

View File

@@ -17,10 +17,7 @@ const cards: FeatureCard[] = [
description: t('mcp.setup.step1.description', locale),
action: {
type: 'code',
value: t('mcp.setup.step1.command', locale).replace(
'{url}',
externalLinks.docsMcp
)
value: externalLinks.mcpServer
}
},
{
@@ -56,8 +53,6 @@ const cards: FeatureCard[] = [
<template>
<FeatureGrid01
id="setup"
class="scroll-mt-24 lg:scroll-mt-36"
:eyebrow="t('mcp.setup.label', locale)"
:heading="t('mcp.setup.heading', locale)"
:subtitle="t('mcp.setup.subtitle', locale)"

View File

@@ -9,25 +9,16 @@ export interface McpCta {
}
/**
* Calls-to-action for the MCP page: view the docs, jump to the on-page setup
* steps, or run a workflow in the cloud. The hero leads with install + docs;
* the "how it works" section pairs run-a-workflow with docs.
* The two calls-to-action shared by the MCP hero and "how it works" sections:
* view the docs, or run a workflow in the cloud.
*/
export function mcpCtas(locale: Locale): {
docs: McpCta
installMcp: McpCta
runWorkflow: McpCta
} {
export function mcpCtas(locale: Locale): { docs: McpCta; runWorkflow: McpCta } {
return {
docs: {
label: t('mcp.hero.viewDocs', locale),
href: externalLinks.docsMcp,
target: '_blank'
},
installMcp: {
label: t('mcp.hero.installMcp', locale),
href: '#setup'
},
runWorkflow: {
label: t('mcp.hero.runWorkflow', locale),
href: getRoutes(locale).cloud

View File

@@ -1,109 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { EvaluableBanner } from './banner'
import { createBannerVersion, evaluateBannerVisibility } from './banner'
const base: EvaluableBanner = {
isActive: true,
targetSections: ['sitewide']
}
const ctx = {
currentLocale: 'en',
currentSection: 'sitewide',
now: new Date('2026-07-06T00:00:00Z')
}
describe('evaluateBannerVisibility', () => {
it('shows an active, untargeted, sitewide banner', () => {
expect(evaluateBannerVisibility(base, ctx)).toBe(true)
})
it('hides when inactive', () => {
expect(evaluateBannerVisibility({ ...base, isActive: false }, ctx)).toBe(
false
)
})
it('hides before startsAt and shows within the window', () => {
expect(
evaluateBannerVisibility(
{ ...base, startsAt: '2026-07-10T00:00:00Z' },
ctx
)
).toBe(false)
expect(
evaluateBannerVisibility(
{ ...base, startsAt: '2026-07-01T00:00:00Z' },
ctx
)
).toBe(true)
})
it('hides after endsAt', () => {
expect(
evaluateBannerVisibility({ ...base, endsAt: '2026-07-01T00:00:00Z' }, ctx)
).toBe(false)
expect(
evaluateBannerVisibility({ ...base, endsAt: '2026-07-10T00:00:00Z' }, ctx)
).toBe(true)
})
it('treats an empty targetLocales as "all locales"', () => {
expect(evaluateBannerVisibility({ ...base, targetLocales: [] }, ctx)).toBe(
true
)
})
it('hides when targetLocales excludes the current locale', () => {
expect(
evaluateBannerVisibility({ ...base, targetLocales: ['zh-CN'] }, ctx)
).toBe(false)
expect(
evaluateBannerVisibility({ ...base, targetLocales: ['en', 'zh-CN'] }, ctx)
).toBe(true)
})
it('hides when targetSections does not include the current section', () => {
expect(
evaluateBannerVisibility({ ...base, targetSections: ['checkout'] }, ctx)
).toBe(false)
})
it('hides when targetSections is absent (nothing to match)', () => {
expect(evaluateBannerVisibility({ isActive: true }, ctx)).toBe(false)
})
})
describe('createBannerVersion', () => {
const content = {
id: 'announcement',
title: 'Join the live stream',
link: { href: 'https://x', title: 'Join' }
}
it('is deterministic for identical content', () => {
expect(createBannerVersion(content, 'en')).toBe(
createBannerVersion(content, 'en')
)
})
it('encodes the banner id and locale in the key', () => {
expect(createBannerVersion(content, 'en')).toMatch(
/^announcement_en_v-?\d+$/
)
})
it('changes when the copy changes', () => {
expect(createBannerVersion(content, 'en')).not.toBe(
createBannerVersion({ ...content, title: 'New copy' }, 'en')
)
})
it('differs per locale so one locale edit does not re-show another', () => {
expect(createBannerVersion(content, 'en')).not.toBe(
createBannerVersion(content, 'zh-CN')
)
})
})

View File

@@ -1,87 +0,0 @@
// Pure, framework-agnostic banner logic — no Vue/Astro/config imports so it stays
// trivially unit-testable. Locale/section are plain strings on purpose.
// Shared dismissal storage contract. The pre-hydration script in BaseLayout.astro,
// the useBannerDismissal composable, and the CSS selector in global.css must all
// agree on these literals — keep them here as the single source of truth.
export const BANNER_STORAGE_KEY = 'closedBanners'
export const BANNER_DISMISS_ATTR = 'data-banner-dismissed'
export interface BannerVisibilityContext {
currentLocale: string
currentSection: string
now: Date
}
export interface EvaluableBanner {
isActive: boolean
startsAt?: string
endsAt?: string
targetLocales?: readonly string[]
targetSections?: readonly string[]
}
/**
* Server/build-time visibility gate. Returns false on the FIRST failing check,
* in order: active flag → start window → end window → locale targeting →
* section targeting. An empty/absent `targetLocales` means "all locales".
*/
export function evaluateBannerVisibility(
banner: EvaluableBanner,
ctx: BannerVisibilityContext
): boolean {
if (!banner.isActive) return false
if (
banner.startsAt &&
ctx.now.getTime() < new Date(banner.startsAt).getTime()
)
return false
if (banner.endsAt && ctx.now.getTime() > new Date(banner.endsAt).getTime())
return false
const targetLocales = banner.targetLocales ?? []
if (targetLocales.length > 0 && !targetLocales.includes(ctx.currentLocale))
return false
const targetSections = banner.targetSections ?? []
if (!targetSections.includes(ctx.currentSection)) return false
return true
}
interface BannerLinkContent {
href: string
title: string
target?: string
rel?: string
buttonVariant?: string
}
export interface BannerVersionContent {
id: string
title: string
description?: string
link?: BannerLinkContent
}
/**
* Content-aware version key. Editing the copy changes the hash, so a previously
* dismissed banner re-appears. Keyed per-locale so a zh-CN edit doesn't re-show
* the banner for en visitors. Format: `${content.id}_${locale}_v${hash}`.
*/
export function createBannerVersion(
content: BannerVersionContent,
locale: string
): string {
const contentString = JSON.stringify({
locale,
title: content.title,
description: content.description,
link: content.link
})
let hash = 0
for (const char of contentString) {
hash = Math.imul(hash, 31) + char.charCodeAt(0)
}
return `${content.id}_${locale}_v${hash}`
}

View File

@@ -1,37 +0,0 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "LoadVideo",
"pos": [50, 120],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "VIDEO",
"type": "VIDEO",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadVideo"
},
"widgets_values": ["video/cloud-video-hash.mp4 [output]", "image"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -11,7 +11,6 @@ import {
WORKSPACE_FEATURE_FLAG
} from '@e2e/fixtures/data/cloudWorkspace'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import { mockWorkspaceTokenMint } from '@e2e/fixtures/utils/workspaceMocks'
interface RoleChangeRequest {
url: string
@@ -93,7 +92,9 @@ export class CloudWorkspaceMockHelper {
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, TEAM_WORKSPACE)
await page.route('**/api/auth/token', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
await page.route('**/api/workspaces', (r) =>

View File

@@ -110,8 +110,7 @@ export const TestIds = {
},
propertiesPanel: {
root: 'properties-panel',
errorsTab: 'panel-tab-errors',
selectionContextStrip: 'selection-context-strip'
errorsTab: 'panel-tab-errors'
},
assets: {
browserModal: 'asset-browser-modal',

View File

@@ -33,27 +33,6 @@ export function member(
}
}
/**
* Stub `POST /api/auth/token` with a valid workspace token for `ws`. Without
* this the mint fails and auth cannot resolve the active workspace.
*/
export async function mockWorkspaceTokenMint(
page: Page,
ws: Pick<WorkspaceWithRole, 'id' | 'name' | 'type' | 'role'>
) {
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
}
/**
* Stub the workspace resolution + members list so the cloud app boots into the
* given workspace with the given roster (drives the original-owner gate).
@@ -67,7 +46,17 @@ export async function mockWorkspace(
if (route.request().method() !== 'GET') return route.fallback()
await route.fulfill(jsonRoute({ workspaces: [ws] }))
})
await mockWorkspaceTokenMint(page, ws)
await page.route('**/api/auth/token', (r) =>
r.fulfill(
jsonRoute({
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: { id: ws.id, name: ws.name, type: ws.type },
role: ws.role,
permissions: []
})
)
)
await page.route('**/api/workspace/members**', (r) =>
r.fulfill(
jsonRoute({

View File

@@ -11,10 +11,6 @@ import type {
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import {
mockWorkspaceTokenMint,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
/**
* Billing facade consumers — FE-933 (B3) regression.
@@ -85,7 +81,6 @@ async function mockCloudBoot(
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.

View File

@@ -7,10 +7,6 @@ import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceAp
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
import {
mockWorkspaceTokenMint,
workspace
} from '@e2e/fixtures/utils/workspaceMocks'
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
@@ -101,7 +97,6 @@ async function mockCloudBoot(page: Page) {
await page.route('**/api/auth/session', (r) =>
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
)
await mockWorkspaceTokenMint(page, workspace('personal', 'owner'))
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
// Single personal workspace.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1,11 +1,6 @@
import { expect, mergeTests } from '@playwright/test'
import type { Page, Route } from '@playwright/test'
import type {
Asset,
GetAllSettingsResponse,
GetSettingByIdResponse,
ListAssetsResponse
} from '@comfyorg/ingest-types'
import type { Asset, ListAssetsResponse } from '@comfyorg/ingest-types'
import {
assetRequestIncludesTag,
@@ -13,7 +8,6 @@ import {
} from '@e2e/fixtures/assetApiFixture'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { WorkspaceStore } from '@e2e/types/globals'
import {
routeObjectInfoFromSetupApi,
setComboInputOptions
@@ -29,11 +23,10 @@ import type { RawJobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
const ossTest = mergeTests(comfyPageFixture, jobsRouteFixture)
const outputHash =
'147257c95a3e957e0deee73a077cfec89da2d906dd086ca70a2b0c897a9591d6e.png'
const outputVideoHash = 'cloud-video-hash.mp4'
const plainVideoFileName = 'plain_video.mp4'
const graphDropPosition = { x: 500, y: 300 }
const missingMediaObservationMs = 1_000
const missingMediaPollMs = 100
const missingMediaUploadObservationMs = 1_000
const missingMediaUploadPollMs = 100
const emptyMediaLoaderNodes = [
{
nodeType: 'LoadImage',
@@ -67,18 +60,6 @@ const cloudOutputAsset: Asset & { hash?: string } = {
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudOutputVideoAsset: Asset & { hash?: string } = {
id: 'test-output-video-hash-001',
name: 'ComfyUI_00001_.mp4',
hash: outputVideoHash,
size: 4_194_304,
mime_type: 'video/mp4',
tags: ['output'],
created_at: '2026-05-01T00:00:00Z',
updated_at: '2026-05-01T00:00:00Z',
last_access_time: '2026-05-01T00:00:00Z'
}
const cloudUploadedVideoAsset: Asset & { hash?: string } = {
id: 'test-uploaded-video-001',
name: plainVideoFileName,
@@ -111,21 +92,10 @@ interface CloudUploadAssetState {
async function routeCloudBootstrapApis(page: Page) {
await page.route('**/api/settings**', async (route) => {
const completedSurveySetting: GetSettingByIdResponse = {
value: { usage: 'personal' }
}
const allSettings: GetAllSettingsResponse = {}
const body = route
.request()
.url()
.includes('/api/settings/onboarding_survey')
? completedSurveySetting
: allSettings
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
body: JSON.stringify({})
})
})
await page.route('**/api/userdata**', async (route) => {
@@ -151,10 +121,7 @@ async function routeCloudBootstrapApis(page: Page) {
})
}
const cloudOutputTest = createCloudAssetsFixture([
cloudOutputAsset,
cloudOutputVideoAsset
]).extend({
const cloudOutputTest = createCloudAssetsFixture([cloudOutputAsset]).extend({
page: async ({ page }, use) => {
await routeCloudBootstrapApis(page)
const unrouteObjectInfo = await routeObjectInfoFromSetupApi(page)
@@ -258,33 +225,6 @@ function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
function isOutputAssetsRequest(url: string) {
return url.includes('/api/assets') && assetRequestIncludesTag(url, 'output')
}
async function waitForOutputAssetsResponse(comfyPage: ComfyPage) {
await comfyPage.page.waitForResponse(
(response) =>
response.status() === 200 && isOutputAssetsRequest(response.url())
)
}
async function getCachedMissingMediaWarningNames(
comfyPage: ComfyPage
): Promise<string[] | null> {
return await comfyPage.page.evaluate(() => {
const workflow = (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow
if (!workflow) return null
return (
workflow.pendingWarnings?.missingMediaCandidates?.map(
(candidate) => candidate.name
) ?? []
)
})
}
async function expectNoErrorsTab(comfyPage: ComfyPage) {
await expect(getErrorOverlay(comfyPage)).toBeHidden()
@@ -387,31 +327,25 @@ async function expectLoadVideoUploading(comfyPage: ComfyPage) {
.toBe(true)
}
async function expectNoMissingMediaForObservationWindow(comfyPage: ComfyPage) {
async function expectNoMissingMediaDuringUpload(comfyPage: ComfyPage) {
await comfyPage.nextFrame()
await comfyPage.nextFrame()
let sawErrorOverlay = false
let sawCachedMissingMedia = false
const startedAt = Date.now()
await expect
.poll(
async () => {
const cachedMissingMedia =
await getCachedMissingMediaWarningNames(comfyPage)
sawCachedMissingMedia =
sawCachedMissingMedia || !!cachedMissingMedia?.length
sawErrorOverlay =
sawErrorOverlay || (await getErrorOverlay(comfyPage).isVisible())
return (
!sawErrorOverlay &&
!sawCachedMissingMedia &&
Date.now() - startedAt >= missingMediaObservationMs
Date.now() - startedAt >= missingMediaUploadObservationMs
)
},
{
timeout: missingMediaObservationMs + missingMediaPollMs * 5,
intervals: [missingMediaPollMs]
timeout: missingMediaUploadObservationMs + missingMediaUploadPollMs * 5,
intervals: [missingMediaUploadPollMs]
}
)
.toBe(true)
@@ -490,7 +424,7 @@ ossTest.describe(
})
await expectLoadVideoUploading(comfyPage)
await expectNoMissingMediaForObservationWindow(comfyPage)
await expectNoMissingMediaDuringUpload(comfyPage)
await delayedUpload.finishUpload()
await expect(getErrorOverlay(comfyPage)).toBeHidden()
@@ -548,30 +482,18 @@ cloudOutputTest.describe(
cloudOutputTest(
'resolves compact annotated output media from output assets',
async ({ comfyPage }) => {
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
async ({ cloudAssetRequests, comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_cloud_output_annotation'
)
await outputAssetsResponse
await expectNoMissingMediaForObservationWindow(comfyPage)
await expectNoErrorsTab(comfyPage)
}
)
cloudOutputTest(
'resolves subfoldered output video media from flat output asset hashes',
async ({ comfyPage }) => {
const outputAssetsResponse = waitForOutputAssetsResponse(comfyPage)
await comfyPage.workflow.loadWorkflow(
'missing/missing_media_cloud_output_video_subfolder'
)
await outputAssetsResponse
await expectNoMissingMediaForObservationWindow(comfyPage)
await expect
.poll(() =>
cloudAssetRequests.some((url) =>
assetRequestIncludesTag(url, 'output')
)
)
.toBe(true)
await expectNoErrorsTab(comfyPage)
}
)
@@ -607,7 +529,7 @@ cloudUploadRaceTest.describe(
})
await expectLoadVideoUploading(comfyPage)
await expectNoMissingMediaForObservationWindow(comfyPage)
await expectNoMissingMediaDuringUpload(comfyPage)
markUploadedCloudAssetAvailable()
await delayedUpload.finishUpload()

View File

@@ -286,7 +286,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await expect(missingModelGroup).toBeHidden()
})
test('Selecting a node keeps all errors visible and shows selection context', async ({
test('Selecting a node filters errors tab to only that node', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -301,25 +301,14 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
await node1.click('title')
await expect(
getMissingModelLabel(missingModelGroup, FAKE_MODEL_NAME)
).toBeVisible()
await expectReferenceBadge(missingModelGroup, 2)
const strip = comfyPage.page.getByTestId(
TestIds.propertiesPanel.selectionContextStrip
)
await expect(strip).toBeVisible()
await expect(
strip,
'The strip count is scoped to the selection, diverging from the global reference badge'
).toContainText('1 error')
missingModelGroup.getByTestId(TestIds.dialogs.missingModelLocate)
).toHaveCount(1)
await comfyPage.canvas.click()
await expect(
strip,
'Deselecting swaps the always-visible strip back to the summary'
).toContainText('2 nodes — 1 error')
await expectReferenceBadge(missingModelGroup, 2)
})
})
@@ -392,7 +381,7 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await expect(missingMediaGroup).toBeHidden()
})
test('Selecting a node keeps all media rows visible and shows selection context', async ({
test('Selecting a node filters errors tab to only that node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_media_multiple')
@@ -414,66 +403,13 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
const node = await comfyPage.nodeOps.getNodeRefById('10')
await node.click('title')
// Selection no longer filters the list — rows stay global and the
// selection is surfaced via the context strip instead.
const strip = comfyPage.page.getByTestId(
TestIds.propertiesPanel.selectionContextStrip
)
await expect(strip).toBeVisible()
await expect(strip).toContainText('1 error')
await expect(mediaRows).toHaveCount(2)
await expect(mediaRows).toHaveCount(1)
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
// Deselecting swaps the always-visible strip back to the summary
await expect(strip).toContainText('2 nodes — 2 errors')
await expect(mediaRows).toHaveCount(2)
})
})
test.describe('Selection emphasis', () => {
test('Selecting a node collapses unrelated groups and highlights its rows', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_nodes_and_media'
)
const missingNodeCard = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodeCard
)
const mediaRow = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaRow
)
const strip = comfyPage.page.getByTestId(
TestIds.propertiesPanel.selectionContextStrip
)
await expect(missingNodeCard).toBeVisible()
await expect(mediaRow).toBeVisible()
await expect(strip).toContainText('2 nodes — 2 errors')
const mediaNode = await comfyPage.nodeOps.getNodeRefById('10')
// The node sits near the canvas top where overlays intercept clicks
await mediaNode.centerOnNode()
await mediaNode.click('title')
// The unrelated missing-node group auto-collapses while the matched
// media row stays visible and is marked as part of the selection
await expect(missingNodeCard).toBeHidden()
await expect(mediaRow).toBeVisible()
await expect(mediaRow).toHaveAttribute('aria-current', 'true')
await expect(strip).toContainText('1 error')
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
// Emphasis ends: the collapsed group re-expands and the strip
// returns to the workflow summary
await expect(missingNodeCard).toBeVisible()
await expect(mediaRow).not.toHaveAttribute('aria-current', 'true')
await expect(strip).toContainText('2 nodes — 2 errors')
})
})
test.describe('Subgraph', () => {
test.beforeEach(async ({ comfyPage }) => {
await cleanupFakeModel(comfyPage)

View File

@@ -1,120 +0,0 @@
# 11. Derived Credential Lifecycle for Cloud Auth
Date: 2026-07-09
## Status
Proposed
<!-- [Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR-NNNN](NNNN-title.md)] -->
## Context
Cloud authentication derives several short-lived credentials from a single
source of truth — the Firebase identity (ID token):
- the **workspace JWT** minted by exchanging the Firebase token (`workspaceAuthStore`),
- the **session cookie** created by POSTing the Firebase token to `/auth/session`
(`useSessionCookie`),
- and consumer state gated on those credentials, such as **subscription status**
(`useSubscription`).
A recurring class of production bugs traces back to how these derived credentials
are kept fresh rather than to any single code path:
- **FE-613** — workspace token exchange is not reactive to Firebase auth state.
Its refresh relies on a `setTimeout` timer that browsers throttle in background
tabs, so a backgrounded session serves an expired workspace JWT and every cloud
call 401s until reload.
- **Workspace/personal oscillation** (PR #13511) — when a valid workspace token is
momentarily absent, `getAuthHeader`/`getAuthToken` silently downgraded to the
personal Firebase token, so requests authenticated as the wrong identity.
- **Run-button toggle loop** (Slack, related to FE-1072) — a Firebase token-refresh
burst on wake/network-swap fans out into concurrent, undeduped subscription
fetches racing an in-flight session-cookie rotation; some land pre-rotation and
return 401/empty, flapping `subscriptionStatus` and the run button.
These are not independent defects. They are symptoms of one design shape: **each
derived credential has its own ad-hoc refresh lifecycle, driven by timers or
one-shot events rather than the source identity, with no coalescing of concurrent
refreshes and with silent fallback to a different identity or a stale value on
failure.** Any credential built this way can go stale, stampede, or downgrade.
## Decision
Treat every derived credential as a pure function of the Firebase identity, and
require all of them to obey the same lifecycle invariants. New auth code must
satisfy these; existing code migrates toward them incrementally.
1. **Single source of truth.** The Firebase identity is authoritative. Workspace
JWT and session cookie are derivations of it, never independent state that can
drift from it.
2. **Valid-on-read.** A caller asking for a credential gets a currently-valid one
or a definitive failure — never a known-expired one. Validity is checked at the
point of use (expiry-aware), not assumed because a background timer _should_
have refreshed. Timers may be an optimization, never the guarantee.
3. **Single-flight.** Concurrent requests for the same credential share one
in-flight mint/refresh. A refresh burst collapses to a single network call.
4. **Fail-closed, never downgrade.** If the correct-scope credential cannot be
obtained, fail the request. Never silently substitute a different identity or
scope (e.g. personal token for a workspace request).
5. **Bounded reactive retry.** Invalidation is driven by the source identity
(`onIdTokenChanged`), not by polling or wall-clock timers alone. A `401` on a
derived credential triggers at most one re-mint and one retry, then surfaces
the error.
6. **Explicit scope.** A credential names the identity/workspace it is for.
Coalesced results are verified against the requested scope before use.
PR #13511 is the first increment: workspace-token recovery is now valid-on-read,
single-flight, fail-closed, and reconciles a revoked workspace instead of
downgrading; subscription-status and session-cookie creation are now
single-flight so a refresh burst can no longer flap them. It intentionally does
**not** yet add the `onIdTokenChanged` subscription FE-613 proposes — recovery is
lazy (on read) rather than reactive (on refresh). Invariant 5 is the remaining
gap and is tracked by FE-950 (Unified Cloud Auth) and FE-963 (reactive 401
re-mint + single retry).
Alternatives considered:
- **Layer more defensive checks per call site.** Rejected: this is what produced
the current state — correctness that depends on every caller remembering to
guard is the defect, not the fix.
- **A single reactive credential store subscribing to Firebase, replacing all
three ad-hoc lifecycles at once.** Deferred, not rejected: it is the target
end-state, but a big-bang rewrite of live auth is too risky. We migrate under
these invariants incrementally instead.
## Consequences
### Positive
- Whole categories of failure become structurally hard rather than individually
patched: stale-on-wake (invariant 2), refresh stampede (3), wrong-identity
requests (4).
- New auth code has a single checklist to satisfy, and reviewers a single rubric
to apply.
- Establishes a shared vocabulary (valid-on-read, single-flight, fail-closed) for
reasoning about auth changes.
### Negative
- Fail-closed surfaces auth failures that silent downgrade previously masked; some
transient conditions now show errors instead of degrading quietly, so
transient-vs-permanent classification must be correct.
- The invariants are not yet fully realized. Until invariant 5 lands, recovery is
lazy and a backgrounded tab still relies on the next read to heal, leaving a
visible gap against FE-613's reactive ideal.
- Existing lifecycles remain non-uniform during migration, so the mental model is
"target vs. current" until the reactive credential store exists.
## Notes
- Related: [ADR-0003](0003-crdt-based-layout-system.md) is unrelated in domain but
shares the philosophy of designing invariants that make illegal states
unrepresentable rather than guarding against them per call site.
- Tickets: FE-613, FE-950, FE-963, FE-1072. PR: #13511.

View File

@@ -20,7 +20,6 @@ An Architecture Decision Record captures an important architectural decision mad
| [0008](0008-entity-component-system.md) | Entity Component System | Proposed | 2026-03-23 |
| [0009](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Subgraph Promoted Widgets Use Linked Inputs | Proposed | 2026-05-05 |
| [0010](0010-remove-nx-orchestration.md) | Remove Nx Orchestration | Accepted | 2026-05-19 |
| [0011](0011-derived-credential-lifecycle.md) | Derived Credential Lifecycle for Cloud Auth | Proposed | 2026-07-09 |
## Creating a New ADR

View File

@@ -4,12 +4,11 @@ This guide provides an overview of testing approaches used in the ComfyUI Fronte
## Testing Documentation
Documentation for unit tests is organized into four guides:
Documentation for unit tests is organized into three guides:
- [Component Testing](./component-testing.md) - How to test Vue components
- [Unit Testing](./unit-testing.md) - How to test utility functions, composables, and other non-component code
- [Store Testing](./store-testing.md) - How to test Pinia stores specifically
- [LiteGraph Testing](./litegraph-testing.md) - How to test LiteGraph graph, node, link, and workflow behavior
## Testing Structure

View File

@@ -1,9 +0,0 @@
# LiteGraph Testing Guide
This guide covers test patterns for LiteGraph graph, node, link, subgraph, and workflow behavior in ComfyUI Frontend.
## Shared Factories
Reuse shared factories in `src/utils/__tests__/litegraphTestUtils.ts` instead of hand-rolling LiteGraph node, canvas, graph, subgraph, or workflow builders.
Use real LiteGraph instances or shared factories when they exercise behavior directly. Avoid mocking LiteGraph classes unless the test is intentionally checking a seam outside LiteGraph itself.

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.48.0",
"version": "1.47.6",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -12,11 +12,6 @@ export type {
AddAssetTagsErrors,
AddAssetTagsResponse,
AddAssetTagsResponses,
AdminDeleteHubWorkflowData,
AdminDeleteHubWorkflowError,
AdminDeleteHubWorkflowErrors,
AdminDeleteHubWorkflowResponse,
AdminDeleteHubWorkflowResponses,
Asset,
AssetCreated,
AssetCreatedWritable,
@@ -47,11 +42,6 @@ export type {
CancelJobErrors,
CancelJobResponse,
CancelJobResponses,
CancelJobsData,
CancelJobsError,
CancelJobsErrors,
CancelJobsResponse,
CancelJobsResponses,
CancelSubscriptionData,
CancelSubscriptionError,
CancelSubscriptionErrors,
@@ -94,11 +84,6 @@ export type {
CreateDeletionRequestErrors,
CreateDeletionRequestResponse,
CreateDeletionRequestResponses,
CreateDesktopLoginCodeData,
CreateDesktopLoginCodeError,
CreateDesktopLoginCodeErrors,
CreateDesktopLoginCodeResponse,
CreateDesktopLoginCodeResponses,
CreateHubAssetUploadUrlData,
CreateHubAssetUploadUrlError,
CreateHubAssetUploadUrlErrors,
@@ -201,31 +186,12 @@ export type {
DeleteWorkspaceResponses,
DeletionRequest,
DeletionStatus,
DesktopLoginCodeCreateRequest,
DesktopLoginCodeCreateResponse,
DesktopLoginCodeExchangeRequest,
DesktopLoginCodeExchangeResponse,
DesktopLoginCodeRedeemRequest,
DesktopLoginCodeRedeemResponse,
DownloadExportData,
DownloadExportError,
DownloadExportErrors,
DownloadExportResponse,
DownloadExportResponses,
EnsureWorkspaceBillingLegacySnapshot,
EnsureWorkspaceBillingProvisionedData,
EnsureWorkspaceBillingProvisionedError,
EnsureWorkspaceBillingProvisionedErrors,
EnsureWorkspaceBillingProvisionedRequest,
EnsureWorkspaceBillingProvisionedResponse,
EnsureWorkspaceBillingProvisionedResponse2,
EnsureWorkspaceBillingProvisionedResponses,
ErrorResponse,
ExchangeDesktopLoginCodeData,
ExchangeDesktopLoginCodeError,
ExchangeDesktopLoginCodeErrors,
ExchangeDesktopLoginCodeResponse,
ExchangeDesktopLoginCodeResponses,
ExchangeTokenData,
ExchangeTokenError,
ExchangeTokenErrors,
@@ -264,11 +230,6 @@ export type {
GetAssetByIdErrors,
GetAssetByIdResponse,
GetAssetByIdResponses,
GetAssetContentData,
GetAssetContentError,
GetAssetContentErrors,
GetAssetContentResponse,
GetAssetContentResponses,
GetAssetSeedStatusData,
GetAssetSeedStatusResponse,
GetAssetSeedStatusResponses,
@@ -342,11 +303,6 @@ export type {
GetHistoryData,
GetHistoryError,
GetHistoryErrors,
GetHistoryEventsData,
GetHistoryEventsError,
GetHistoryEventsErrors,
GetHistoryEventsResponse,
GetHistoryEventsResponses,
GetHistoryForPromptData,
GetHistoryForPromptError,
GetHistoryForPromptErrors,
@@ -389,6 +345,8 @@ export type {
GetJwksData,
GetJwksResponse,
GetJwksResponses,
GetLegacyAssetContentData,
GetLegacyAssetContentErrors,
GetLegacyHistoryByIdData,
GetLegacyHistoryByIdErrors,
GetLegacyHistoryData,
@@ -598,7 +556,6 @@ export type {
HistoryDetailEntry,
HistoryDetailResponse,
HistoryEntry,
HistoryEventRequest,
HistoryManageRequest,
HistoryResponse,
HubAssetUploadUrlRequest,
@@ -632,8 +589,6 @@ export type {
JobCancelResponse,
JobDetailResponse,
JobEntry,
JobsCancelRequest,
JobsCancelResponse,
JobsListResponse,
JobStatusResponse,
JwkKey,
@@ -672,19 +627,7 @@ export type {
ListJobsErrors,
ListJobsResponse,
ListJobsResponses,
ListLinkedFirebaseUidsData,
ListLinkedFirebaseUidsError,
ListLinkedFirebaseUidsErrors,
ListLinkedFirebaseUidsRequest,
ListLinkedFirebaseUidsResponse,
ListLinkedFirebaseUidsResponse2,
ListLinkedFirebaseUidsResponses,
ListMembersResponse,
ListSecretProvidersData,
ListSecretProvidersError,
ListSecretProvidersErrors,
ListSecretProvidersResponse,
ListSecretProvidersResponses,
ListSecretsData,
ListSecretsError,
ListSecretsErrors,
@@ -832,17 +775,6 @@ export type {
QueueInfo,
QueueManageRequest,
QueueManageResponse,
RedeemDesktopLoginCodeData,
RedeemDesktopLoginCodeError,
RedeemDesktopLoginCodeErrors,
RedeemDesktopLoginCodeResponse,
RedeemDesktopLoginCodeResponses,
ReleaseDeletionHoldData,
ReleaseDeletionHoldError,
ReleaseDeletionHoldErrors,
ReleaseDeletionHoldResponse,
ReleaseDeletionHoldResponses,
ReleaseHoldResponse,
RemoveAssetTagsData,
RemoveAssetTagsError,
RemoveAssetTagsErrors,
@@ -853,11 +785,6 @@ export type {
RemoveWorkspaceMemberErrors,
RemoveWorkspaceMemberResponse,
RemoveWorkspaceMemberResponses,
ReportHistoryEventData,
ReportHistoryEventError,
ReportHistoryEventErrors,
ReportHistoryEventResponse,
ReportHistoryEventResponses,
ReportPartnerUsageData,
ReportPartnerUsageError,
ReportPartnerUsageErrors,
@@ -881,8 +808,6 @@ export type {
RevokeWorkspaceInviteResponse,
RevokeWorkspaceInviteResponses,
SecretListResponse,
SecretProvider,
SecretProvidersResponse,
SecretResponse,
SeedAssetsData,
SeedAssetsResponse,
@@ -894,8 +819,6 @@ export type {
SetReviewStatusResponse,
SetReviewStatusResponse2,
SetReviewStatusResponses,
ShortLinkRedirectData,
ShortLinkRedirectErrors,
SubmitFeedbackData,
SubmitFeedbackError,
SubmitFeedbackErrors,
@@ -925,10 +848,6 @@ export type {
TaskEntry,
TaskResponse,
TasksListResponse,
TeamCreditStop,
TeamCreditStopPrice,
TeamCreditStops,
TeamCreditStopSummary,
UpdateAssetData,
UpdateAssetError,
UpdateAssetErrors,
@@ -946,7 +865,6 @@ export type {
UpdateHubWorkflowRequest,
UpdateHubWorkflowResponse,
UpdateHubWorkflowResponses,
UpdateMemberRoleRequest,
UpdateMultipleSettingsData,
UpdateMultipleSettingsError,
UpdateMultipleSettingsErrors,
@@ -977,11 +895,6 @@ export type {
UpdateWorkspaceData,
UpdateWorkspaceError,
UpdateWorkspaceErrors,
UpdateWorkspaceMemberRoleData,
UpdateWorkspaceMemberRoleError,
UpdateWorkspaceMemberRoleErrors,
UpdateWorkspaceMemberRoleResponse,
UpdateWorkspaceMemberRoleResponses,
UpdateWorkspaceRequest,
UpdateWorkspaceResponse,
UpdateWorkspaceResponses,

File diff suppressed because it is too large Load Diff

View File

@@ -465,20 +465,6 @@ export const zCreateWorkflowRequest = z.object({
forked_from_workflow_version_id: z.string().optional()
})
/**
* Request body for forwarding a comfy-api audit/history event. Identify the target workspace by either user_id (cloud resolves the user's personal workspace via the converged identity, BE-1047) or an explicit workspace_id. At least one must be provided; workspace_id wins when both are set.
*/
export const zHistoryEventRequest = z.object({
user_id: z.string().optional(),
workspace_id: z.string().optional(),
event_type: z.string().min(1),
event_id: z.string().min(1),
params: z.record(z.unknown()).optional(),
auth_method: z.enum(['api_key', 'bearer_token']).optional(),
customer_ref: z.string().optional(),
timestamp: z.string().datetime().optional()
})
/**
* Response after recording partner usage data.
*/
@@ -554,11 +540,11 @@ export const zPaymentPortalRequest = z.object({
})
/**
* Response after accepting a resubscribe request.
* Response after successfully resubscribing to a billing plan.
*/
export const zResubscribeResponse = z.object({
billing_op_id: z.string(),
status: z.enum(['active', 'pending']),
status: z.enum(['active']),
message: z.string().optional()
})
@@ -599,8 +585,6 @@ export const zSubscribeResponse = z.object({
*/
export const zSubscribeRequest = z.object({
plan_slug: z.string(),
team_credit_stop_id: z.string().optional(),
billing_cycle: z.enum(['monthly', 'yearly']).optional(),
idempotency_key: z.string().optional(),
return_url: z.string().optional(),
cancel_url: z.string().optional()
@@ -642,8 +626,7 @@ export const zSubscriptionTier = z.enum([
'STANDARD',
'CREATOR',
'PRO',
'FOUNDERS_EDITION',
'TEAM'
'FOUNDERS_EDITION'
])
/**
@@ -731,57 +714,6 @@ export const zPreviewSubscribeRequest = z.object({
plan_slug: z.string()
})
/**
* Pre/post-discount price for a team credit stop, in cents.
*/
export const zTeamCreditStopPrice = z.object({
list_price_cents: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
price_cents: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
})
/**
* A selectable preset on the team pricing slider. Echoed on subscribe via
* team_credit_stop_id; the backend owns the resolved amounts. credits is a
* RAW monthly credit count (not cents). Save% is derived by the FE as
* (list_price_cents - price_cents) / list_price_cents.
*
*/
export const zTeamCreditStop = z.object({
id: z.string(),
credits: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
monthly: zTeamCreditStopPrice,
yearly: zTeamCreditStopPrice
})
/**
* Credit-stop ladder for the pricing slider (BE-1254). Returned by GET /api/billing/plans for every workspace regardless of the caller's token or workspace type (the personal/team distinction was removed); omitted only when the catalog defines no stops.
*/
export const zTeamCreditStops = z.object({
default_stop_index: z.number().int(),
stops: z.array(zTeamCreditStop)
})
/**
* Reason why a plan is unavailable
*/
@@ -841,50 +773,7 @@ export const zPlan = z.object({
*/
export const zBillingPlansResponse = z.object({
current_plan_slug: z.string().optional(),
plans: z.array(zPlan),
team_credit_stops: zTeamCreditStops.optional()
})
/**
* The team credit stop a workspace is currently subscribed to: the
* per-workspace slider choice recorded at subscribe time
* (workspace_subscriptions.team_credit_stop_id). Amounts are owned by the
* catalog, not the subscription row. Returned on GET /api/billing/status
* for per-credit Team plans (BE-1254).
*
*/
export const zTeamCreditStopSummary = z.object({
id: z.string(),
credits_monthly: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
stop_usd: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
})
/**
* A provider the user may configure a secret for. The shape is deliberately minimal (identifier only) and reserved for future per-provider fields such as sub-keys.
*/
export const zSecretProvider = z.object({
id: z.string()
})
/**
* The providers available to the authenticated user in the current workspace.
*/
export const zSecretProvidersResponse = z.object({
data: z.array(zSecretProvider)
plans: z.array(zPlan)
})
/**
@@ -924,7 +813,7 @@ export const zCreateSecretRequest = z.object({
})
/**
* A single history event. The cloud history-events store is the single source of truth for both billing events (charges, credits, adjustments) and user-facing usage events.
* A single billing event such as a charge, credit, or adjustment.
*/
export const zBillingEvent = z.object({
event_type: z.string(),
@@ -979,8 +868,7 @@ export const zBillingStatusResponse = z.object({
billing_status: zBillingStatus.optional(),
has_funds: z.boolean(),
cancel_at: z.string().datetime().optional(),
renewal_date: z.string().datetime().optional(),
team_credit_stop: zTeamCreditStopSummary.nullable()
renewal_date: z.string().datetime().optional()
})
/**
@@ -1042,7 +930,6 @@ export const zOAuthConsentChallenge = z.object({
csrf_token: z.string(),
client_display_name: z.string(),
resource_display_name: z.string(),
redirect_uri: z.string().url(),
scopes: z.array(z.string()),
workspaces: z.array(zOAuthConsentChallengeWorkspace)
})
@@ -1169,66 +1056,6 @@ export const zSyncApiKeyRequest = z.object({
customer_id: z.string().min(1)
})
/**
* The personal workspace's provisioned billing identity.
*/
export const zEnsureWorkspaceBillingProvisionedResponse = z.object({
workspace_id: z.string(),
stripe_customer_id: z.string(),
metronome_customer_id: z.string(),
metronome_contract_id: z.string()
})
/**
* The caller's already-resolved legacy (comfy-api) customer identity. When
* present and carrying provider IDs, provisioning ATTACHES this identity to
* the personal workspace (sharing the existing balance and subscription)
* instead of minting a net-new empty customer. Omit (or send with no
* provider IDs) for a free user with nothing to attach — provisioning then
* creates net-new. This closes the create-new-before-attach gap: a caller
* that already knows the legacy identity hands it over so the very first
* provisioning is an attach.
*
*/
export const zEnsureWorkspaceBillingLegacySnapshot = z.object({
stripe_customer_id: z.string().optional(),
metronome_customer_id: z.string().optional(),
metronome_contract_id: z.string().optional(),
has_funds: z.boolean().optional(),
subscription_tier: z.string().optional(),
legacy_stripe_subscription_id: z.string().optional(),
legacy_comfy_user_id: z.string().optional()
})
/**
* Request body for ensuring a user's personal workspace carries a fully
* provisioned billing identity. Sent by comfy-api's CreateCustomer (BE-1047)
* with the already canonical-resolved user identity.
*
*/
export const zEnsureWorkspaceBillingProvisionedRequest = z.object({
user_id: z.string().min(1),
email: z.string().email().min(1),
snapshot: zEnsureWorkspaceBillingLegacySnapshot.optional()
})
/**
* Firebase UIDs linked to the canonical comfy_user_id. Empty list when
* no mappings exist (not an error — callers can treat empty as "unknown
* canonical").
*
*/
export const zListLinkedFirebaseUidsResponse = z.object({
firebase_uids: z.array(z.string())
})
/**
* Request body for reverse-looking-up Firebase UIDs linked to a canonical comfy_user_id.
*/
export const zListLinkedFirebaseUidsRequest = z.object({
comfy_user_id: z.string().min(1)
})
/**
* Response confirming the validity and scope of a workspace API key.
*/
@@ -1345,8 +1172,7 @@ export const zMember = z.object({
name: z.string(),
email: z.string().email(),
role: z.enum(['owner', 'member']),
joined_at: z.string().datetime(),
is_original_owner: z.boolean()
joined_at: z.string().datetime()
})
/**
@@ -1357,13 +1183,6 @@ export const zListMembersResponse = z.object({
pagination: zPaginationInfo
})
/**
* Request body for changing a workspace member's role.
*/
export const zUpdateMemberRoleRequest = z.object({
role: z.enum(['owner', 'member'])
})
/**
* Request body for updating an existing workspace's settings.
*/
@@ -1408,60 +1227,6 @@ export const zWorkspace = z.object({
created_at: z.string().datetime()
})
/**
* Exchange poll result. Pending until the code is redeemed in the browser.
*/
export const zDesktopLoginCodeExchangeResponse = z.object({
status: z.enum(['pending', 'complete']),
custom_token: z.string().optional()
})
/**
* Request to exchange a redeemed login code for a custom token.
*/
export const zDesktopLoginCodeExchangeRequest = z.object({
code: z.string(),
code_verifier: z.string().min(43).max(128)
})
/**
* Result of redeeming a desktop login code.
*/
export const zDesktopLoginCodeRedeemResponse = z.object({
status: z.enum(['redeemed'])
})
/**
* Request to claim a desktop login code for the authenticated user.
*/
export const zDesktopLoginCodeRedeemRequest = z.object({
code: z.string()
})
/**
* A freshly minted desktop login code and its polling parameters.
*/
export const zDesktopLoginCodeCreateResponse = z.object({
code: z.string(),
expires_in: z.number().int(),
poll_interval: z.number().int()
})
/**
* Request to mint a desktop login code.
*/
export const zDesktopLoginCodeCreateRequest = z.object({
installation_id: z
.string()
.min(8)
.max(128)
.regex(/^[A-Za-z0-9._-]+$/)
.optional(),
platform: z.string().min(1).max(32),
app_version: z.string().min(1).max(64),
code_challenge: z.string().min(43).max(128)
})
/**
* Abbreviated workspace metadata used in list responses.
*/
@@ -1529,15 +1294,6 @@ export const zTasksListResponse = z.object({
pagination: zPaginationInfo
})
/**
* Result of authorizing a legal-hold release on a user's deletion.
*/
export const zReleaseHoldResponse = z.object({
firebase_id: z.string(),
released: z.boolean(),
message: z.string()
})
/**
* Current status of a user data deletion request.
*/
@@ -1607,20 +1363,6 @@ export const zJobDetailResponse = z.object({
execution_meta: z.record(z.unknown()).optional()
})
/**
* Response for POST /api/jobs/cancel.
*/
export const zJobsCancelResponse = z.object({
cancelled: z.array(z.string())
})
/**
* Request to cancel multiple jobs by ID.
*/
export const zJobsCancelRequest = z.object({
job_ids: z.array(z.string().uuid()).min(1).max(100)
})
/**
* Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops.
*/
@@ -1787,7 +1529,6 @@ export const zAsset = z.object({
user_metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.unknown()).readonly().optional(),
preview_url: z.string().url().optional(),
short_url: z.string().nullish(),
preview_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
@@ -1883,7 +1624,6 @@ export const zSystemStatsResponse = z.object({
python_version: z.string(),
embedded_python: z.boolean(),
comfyui_version: z.string(),
deploy_environment: z.string().optional(),
comfyui_frontend_version: z.string().optional(),
workflow_templates_version: z.string().optional(),
cloud_version: z.string().optional(),
@@ -2222,7 +1962,6 @@ export const zAssetWritable = z.object({
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_url: z.string().url().optional(),
short_url: z.string().nullish(),
preview_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
@@ -2441,11 +2180,7 @@ export const zGetJobDetailData = z.object({
path: z.object({
job_id: z.string().uuid()
}),
query: z
.object({
short_link: z.enum(['ephemeral_tool_chain', 'default']).optional()
})
.optional()
query: z.never().optional()
})
/**
@@ -2466,17 +2201,6 @@ export const zCancelJobData = z.object({
*/
export const zCancelJobResponse = zJobCancelResponse
export const zCancelJobsData = z.object({
body: zJobsCancelRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success - cancel requests dispatched (or jobs were already terminal)
*/
export const zCancelJobsResponse = zJobsCancelResponse
export const zViewFileData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2856,17 +2580,6 @@ export const zCreateSecretData = z.object({
*/
export const zCreateSecretResponse = zSecretResponse
export const zListSecretProvidersData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success
*/
export const zListSecretProvidersResponse = zSecretProvidersResponse
export const zDeleteSecretData = z.object({
body: z.never().optional(),
path: z.object({
@@ -3168,40 +2881,6 @@ export const zExchangeTokenData = z.object({
*/
export const zExchangeTokenResponse2 = zExchangeTokenResponse
export const zCreateDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeCreateRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Login code created
*/
export const zCreateDesktopLoginCodeResponse = zDesktopLoginCodeCreateResponse
export const zRedeemDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeRedeemRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Code redeemed (or already redeemed by the same user)
*/
export const zRedeemDesktopLoginCodeResponse = zDesktopLoginCodeRedeemResponse
export const zExchangeDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeExchangeRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Pending (not yet redeemed) or complete with a custom token
*/
export const zExchangeDesktopLoginCodeResponse =
zDesktopLoginCodeExchangeResponse
export const zGetJwksData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3471,19 +3150,6 @@ export const zRemoveWorkspaceMemberData = z.object({
*/
export const zRemoveWorkspaceMemberResponse = z.void()
export const zUpdateWorkspaceMemberRoleData = z.object({
body: zUpdateMemberRoleRequest,
path: z.object({
userId: z.string()
}),
query: z.never().optional()
})
/**
* Member role updated
*/
export const zUpdateWorkspaceMemberRoleResponse = zMember
export const zListWorkspaceApiKeysData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3570,19 +3236,6 @@ export const zSetReviewStatusData = z.object({
*/
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
export const zAdminDeleteHubWorkflowData = z.object({
body: z.never().optional(),
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Successfully deleted
*/
export const zAdminDeleteHubWorkflowResponse = z.void()
export const zUpdateHubWorkflowData = z.object({
body: zUpdateHubWorkflowRequest,
path: z.object({
@@ -3624,19 +3277,6 @@ export const zCreateDeletionRequestResponse = z.object({
user_found_in_cloud: z.boolean()
})
export const zReleaseDeletionHoldData = z.object({
body: z.object({
firebase_id: z.string()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Release authorized; the deletion workflow will proceed
*/
export const zReleaseDeletionHoldResponse = zReleaseHoldResponse
export const zReportPartnerUsageData = z.object({
body: zPartnerUsageRequest,
path: z.never().optional(),
@@ -3648,38 +3288,6 @@ export const zReportPartnerUsageData = z.object({
*/
export const zReportPartnerUsageResponse = zPartnerUsageResponse
export const zGetHistoryEventsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
workspace_id: z.string().optional(),
user_id: z.string().optional(),
event_type: z.string().optional(),
start_date: z.string().datetime().optional(),
end_date: z.string().datetime().optional(),
page: z.number().int().optional(),
limit: z.number().int().optional()
})
.optional()
})
/**
* Paginated cloud history events for the workspace
*/
export const zGetHistoryEventsResponse = zBillingEventsResponse
export const zReportHistoryEventData = z.object({
body: zHistoryEventRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* History event recorded successfully
*/
export const zReportHistoryEventResponse = zPartnerUsageResponse
export const zUpdateSubscriptionCacheData = z.object({
body: z.object({
user_id: z.string(),
@@ -3697,29 +3305,6 @@ export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
export const zListLinkedFirebaseUidsData = z.object({
body: zListLinkedFirebaseUidsRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Linked Firebase UIDs (possibly empty list)
*/
export const zListLinkedFirebaseUidsResponse2 = zListLinkedFirebaseUidsResponse
export const zEnsureWorkspaceBillingProvisionedData = z.object({
body: zEnsureWorkspaceBillingProvisionedRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* The workspace's provisioned billing identity
*/
export const zEnsureWorkspaceBillingProvisionedResponse2 =
zEnsureWorkspaceBillingProvisionedResponse
export const zInsertDynamicConfigData = z.object({
body: z.record(z.unknown()),
path: z.never().optional(),
@@ -4425,14 +4010,6 @@ export const zGetModelPreviewData = z.object({
query: z.never().optional()
})
export const zShortLinkRedirectData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z.never().optional()
})
export const zGetLegacyPromptByIdData = z.object({
body: z.never().optional(),
path: z.object({
@@ -4493,23 +4070,14 @@ export const zGetLegacyUserdataV2Data = z.object({
query: z.never().optional()
})
export const zGetAssetContentData = z.object({
export const zGetLegacyAssetContentData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z
.object({
disposition: z.enum(['inline', 'attachment']).optional()
})
.optional()
query: z.never().optional()
})
/**
* Asset content stream (local runtime streams the bytes directly)
*/
export const zGetAssetContentResponse = z.string()
export const zGetLegacyViewMetadataData = z.object({
body: z.never().optional(),
path: z.object({

View File

@@ -4,5 +4,5 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
"include": ["src/**/*", "*.config.ts"]
}

View File

@@ -4,5 +4,5 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
"include": ["src/**/*", "vitest.config.ts"]
}

View File

@@ -414,15 +414,15 @@ describe('formatUtil', () => {
})
describe('isPreviewableMediaType', () => {
it('returns true for image/video/audio/3D/text', () => {
it('returns true for image/video/audio/3D', () => {
expect(isPreviewableMediaType('image')).toBe(true)
expect(isPreviewableMediaType('video')).toBe(true)
expect(isPreviewableMediaType('audio')).toBe(true)
expect(isPreviewableMediaType('3D')).toBe(true)
expect(isPreviewableMediaType('text')).toBe(true)
})
it('returns false for other', () => {
it('returns false for text/other', () => {
expect(isPreviewableMediaType('text')).toBe(false)
expect(isPreviewableMediaType('other')).toBe(false)
})
})

View File

@@ -677,7 +677,12 @@ export function getMediaTypeFromFilename(
}
export function isPreviewableMediaType(mediaType: MediaType): boolean {
return mediaType !== 'other'
return (
mediaType === 'image' ||
mediaType === 'video' ||
mediaType === 'audio' ||
mediaType === '3D'
)
}
export function formatTime(seconds: number): string {

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Google Gemini">
<path d="M12 1c.6 5.4 4.6 9.4 10 10-5.4.6-9.4 4.6-10 10-.6-5.4-4.6-9.4-10-10 5.4-.6 9.4-4.6 10-10z" fill="#4285F4"/>
</svg>

Before

Width:  |  Height:  |  Size: 248 B

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" role="img" aria-label="Runway">
<rect width="24" height="24" rx="5" fill="#6E56CF"/>
<path d="M9.5 8.2v7.6l6.3-3.8z" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 228 B

View File

@@ -35,10 +35,10 @@
:class="
sidebarLocation === 'left'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
: 'pointer-events-auto bg-comfy-menu-bg'
"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
@@ -82,7 +82,7 @@
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible && !focusMode"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
>
<slot name="bottom-panel" />
</SplitterPanel>
@@ -95,10 +95,10 @@
:class="
sidebarLocation === 'right'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
: 'pointer-events-auto bg-comfy-menu-bg'
"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import type { MenuItem } from 'primevue/menuitem'
import {
DropdownMenuArrow,
@@ -11,15 +12,21 @@ import { computed, ref, toValue } from 'vue'
import DropdownItem from '@/components/common/DropdownItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
import { cn } from '@comfyorg/tailwind-utils'
import type { ButtonVariants } from '../ui/button/button.variants'
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
const MODAL_BASE_Z_INDEX = 1700
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 +34,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
contentClass?: string
buttonSize?: ButtonVariants['size']
buttonClass?: string
modal?: boolean
}>()
const itemClass = computed(() =>
@@ -43,12 +51,19 @@ const contentClass = computed(() =>
)
)
// Body-portaled content keeps its static z-1700 unless a dialog that joined
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
// past that dialog so the menu isn't hidden behind it.
const open = ref(false)
const contentStyle = useModalLiftedZIndex(open)
const contentStyle = computed(() => {
if (!open.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
})
</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,40 @@
<template>
<div class="relative mx-2">
<div
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'
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

@@ -1,10 +1,10 @@
import { createTestingPinia } from '@pinia/testing'
import { cleanup, render, screen, waitFor } from '@testing-library/vue'
import { cleanup, render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { defineComponent, h, nextTick } from 'vue'
import { defineComponent, h } from 'vue'
import { createI18n } from 'vue-i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
@@ -12,9 +12,6 @@ import {
onRekaFocusOutside,
onRekaPointerDownOutside
} from '@/components/dialog/rekaPrimeVueBridge'
import UiDialog from '@/components/ui/dialog/Dialog.vue'
import UiDialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import UiDialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import { useDialogStore } from '@/stores/dialogStore'
const i18n = createI18n({
@@ -32,14 +29,6 @@ const Body = defineComponent({
setup: () => () => h('p', { 'data-testid': 'body' }, 'body content')
})
const ClosedNonModalDialog = defineComponent({
name: 'ClosedNonModalDialog',
setup: () => () =>
h(UiDialog, { open: false, modal: false }, () =>
h(UiDialogPortal, null, () => h(UiDialogOverlay))
)
})
function mountDialog() {
return render(GlobalDialog, {
global: { plugins: [PrimeVue, i18n] }
@@ -286,81 +275,6 @@ describe('GlobalDialog Reka parity with PrimeVue', () => {
})
})
describe('GlobalDialog Reka overlay scrim', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
afterEach(() => {
cleanup()
})
it('renders a backdrop scrim for modal Reka dialogs', async () => {
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-modal-scrim',
title: 'Modal',
component: Body,
dialogComponentProps: { renderer: 'reka' }
})
await screen.findByRole('dialog')
expect(screen.queryAllByTestId('dialog-overlay')).toHaveLength(1)
})
it('shows a backdrop scrim while a non-modal Reka dialog is open', async () => {
// Reka's own DialogOverlay renders nothing when the root is non-modal,
// which silently dropped the scrim behind Settings/Manager (modal: false).
mountDialog()
const store = useDialogStore()
store.showDialog({
key: 'reka-non-modal-scrim',
title: 'Non-modal',
component: Body,
dialogComponentProps: { renderer: 'reka', modal: false }
})
await screen.findByRole('dialog')
expect(screen.queryAllByTestId('dialog-overlay')).toHaveLength(1)
store.closeDialog({ key: 'reka-non-modal-scrim' })
await waitFor(() =>
expect(screen.queryAllByTestId('dialog-overlay')).toHaveLength(0)
)
})
it('renders no scrim for a mounted but closed non-modal dialog', async () => {
// CustomizationDialog mounts its non-modal Dialog root with open=false;
// the scrim must stay gated on open, not just on mount.
render(ClosedNonModalDialog)
await nextTick()
expect(screen.queryAllByTestId('dialog-overlay')).toHaveLength(0)
})
it('dismisses the dialog on a scrim pointerdown', async () => {
mountDialog()
const store = useDialogStore()
const user = userEvent.setup()
store.showDialog({
key: 'reka-scrim-dismiss',
title: 'Non-modal',
component: Body,
dialogComponentProps: { renderer: 'reka', modal: false }
})
await screen.findByRole('dialog')
await user.click(screen.getByTestId('dialog-overlay'))
await waitFor(() =>
expect(store.isDialogOpen('reka-scrim-dismiss')).toBe(false)
)
})
})
describe('shouldPreventRekaDismiss', () => {
function makeEvent(target: Element | null) {
let prevented = false
@@ -404,6 +318,22 @@ describe('shouldPreventRekaDismiss', () => {
expect(event.defaultPrevented).toBe(false)
})
it('prevents dismiss when clicking a menu trigger to close it', () => {
// A DropdownMenu trigger inside a dialog: clicking it again to close the
// menu must not tear down the surrounding dialog.
const trigger = document.createElement('button')
trigger.setAttribute('aria-haspopup', 'menu')
const icon = document.createElement('i')
trigger.appendChild(icon)
document.body.appendChild(trigger)
const event = makeEvent(icon)
onRekaPointerDownOutside({ dismissableMask: undefined }, event)
expect(event.defaultPrevented).toBe(true)
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 @@ import Password from 'primevue/password'
import PrimeVue from 'primevue/config'
import ProgressSpinner from 'primevue/progressspinner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, h, nextTick, ref } from 'vue'
import { defineComponent, h, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -38,45 +38,29 @@ vi.mock('@/stores/authStore', () => ({
}))
const mockTurnstileEnabled = ref(false)
const mockTurnstileToken = ref('')
const mockTurnstileUnavailable = ref(false)
const mockTurnstileEnforced = ref(false)
const mockReset = vi.fn()
let emitTurnstileToken: ((token: string) => void) | undefined
let emitTurnstileUnavailable: ((unavailable: boolean) => void) | undefined
// The reset-on-toggle behavior lives in useTurnstileGate itself (see
// useTurnstile.test.ts); this fake just wires token/unavailable through to
// `waiting` the same way so SignUpForm's submit gating can be exercised.
vi.mock('@/composables/auth/useTurnstile', () => ({
useTurnstile: () => ({
enabled: mockTurnstileEnabled
}),
useTurnstileGate: () => ({
token: mockTurnstileToken,
unavailable: mockTurnstileUnavailable,
waiting: computed(
() =>
mockTurnstileEnabled.value &&
!mockTurnstileToken.value &&
!mockTurnstileUnavailable.value
)
enabled: mockTurnstileEnabled,
enforced: mockTurnstileEnforced
})
}))
// Stub the real widget (which loads the external Turnstile script) with one that
// exposes a spyable reset() and lets a test drive the v-model token/unavailable
// the way a solved challenge (or a broken/slow widget) would.
// exposes a spyable reset() and lets a test drive the v-model token the way a
// solved challenge would.
vi.mock('./TurnstileWidget.vue', async () => {
const { defineComponent: defineMock } = await import('vue')
return {
default: defineMock({
name: 'TurnstileWidget',
emits: ['update:token', 'update:unavailable'],
emits: ['update:token'],
setup(_, { expose, emit }) {
expose({ reset: mockReset })
emitTurnstileToken = (token: string) => emit('update:token', token)
emitTurnstileUnavailable = (unavailable: boolean) =>
emit('update:unavailable', unavailable)
return () => null
}
})
@@ -108,11 +92,9 @@ describe('SignUpForm', () => {
beforeEach(() => {
mockLoadingRef.value = false
mockTurnstileEnabled.value = false
mockTurnstileToken.value = ''
mockTurnstileUnavailable.value = false
mockTurnstileEnforced.value = false
mockReset.mockClear()
emitTurnstileToken = undefined
emitTurnstileUnavailable = undefined
})
afterEach(() => {
@@ -229,22 +211,43 @@ describe('SignUpForm', () => {
})
})
// Regression coverage for the shadow-mode race: previously submit was only
// gated in 'enforce' mode, so most real signups in 'shadow' mode raced
// ahead of the async Cloudflare challenge and reached the backend with an
// empty token. Gating now depends only on whether the widget is enabled
// (shadow or enforce both render it), so both modes behave identically here.
describe('Turnstile submit gating', () => {
it('disables the submit button until a token is present', async () => {
describe('Turnstile token hygiene', () => {
it('clears the stale token when Turnstile becomes disabled', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const { user } = renderComponent()
await fillValidSignup(user)
emitTurnstileToken!('stale-token')
await nextTick()
expect(
screen.getByRole('button', { name: signUpButton })
).not.toBeDisabled()
mockTurnstileEnabled.value = false
await nextTick()
// re-enable: the stale token must have been cleared so submit is blocked again
mockTurnstileEnabled.value = true
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
})
describe('Turnstile submit gating', () => {
it('disables the submit button in enforce mode until a token is present', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
renderComponent()
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
it('does not emit submit while the token is empty', async () => {
it('does not emit submit in enforce mode while the token is empty', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
@@ -254,8 +257,9 @@ describe('SignUpForm', () => {
expect(onSubmit).not.toHaveBeenCalled()
})
it('emits submit with the token once the challenge is solved', async () => {
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
@@ -267,14 +271,13 @@ describe('SignUpForm', () => {
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
})
it('emits submit without a token once the widget reports itself unavailable (broken/slow load fallback)', async () => {
it('emits submit without a token in shadow mode (never blocks)', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = false
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
emitTurnstileUnavailable!(true)
await nextTick()
await user.click(screen.getByRole('button', { name: signUpButton }))
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)

View File

@@ -33,11 +33,10 @@
v-if="turnstileEnabled"
ref="turnstileWidget"
v-model:token="turnstileToken"
v-model:unavailable="turnstileUnavailable"
/>
<small
v-show="waitingForTurnstile"
v-show="submitBlockedByTurnstile"
id="comfy-org-sign-up-turnstile-hint"
role="status"
aria-live="polite"
@@ -52,9 +51,11 @@
v-else
type="submit"
class="mt-4 h-10 font-medium"
:disabled="!$form.valid || waitingForTurnstile"
:disabled="!$form.valid || submitBlockedByTurnstile"
:aria-describedby="
waitingForTurnstile ? 'comfy-org-sign-up-turnstile-hint' : undefined
submitBlockedByTurnstile
? 'comfy-org-sign-up-turnstile-hint'
: undefined
"
>
{{ t('auth.signup.signUpButton') }}
@@ -69,11 +70,11 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
import { useThrottleFn } from '@vueuse/core'
import InputText from 'primevue/inputtext'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, useTemplateRef } from 'vue'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTurnstile, useTurnstileGate } from '@/composables/auth/useTurnstile'
import { useTurnstile } from '@/composables/auth/useTurnstile'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
import { useAuthStore } from '@/stores/authStore'
@@ -85,21 +86,25 @@ const { t } = useI18n()
const authStore = useAuthStore()
const loading = computed(() => authStore.loading)
const { enabled: turnstileEnabled } = useTurnstile()
const {
token: turnstileToken,
unavailable: turnstileUnavailable,
waiting: waitingForTurnstile
} = useTurnstileGate(turnstileEnabled)
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
useTurnstile()
const turnstileToken = ref('')
const turnstileWidget =
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
const submitBlockedByTurnstile = computed(
() => turnstileEnforced.value && !turnstileToken.value
)
watch(turnstileEnabled, (on) => {
if (!on) turnstileToken.value = ''
})
const emit = defineEmits<{
submit: [values: SignUpData, turnstileToken?: string]
}>()
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
if (event.valid && !waitingForTurnstile.value) {
if (event.valid && !submitBlockedByTurnstile.value) {
emit(
'submit',
event.values as SignUpData,

View File

@@ -261,138 +261,4 @@ describe('TurnstileWidget', () => {
expect(api.remove).toHaveBeenCalledWith('widget-id')
})
// A widget that never resolves (broken script, ad-blocker, CDN outage, or a
// hung challenge) must eventually tell the parent it cannot be relied on,
// so submission can fall back instead of blocking a legitimate signup
// forever.
describe('unavailable fallback', () => {
it('reports unavailable when the Turnstile script fails to load', async () => {
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
const { emitted } = renderWidget()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
})
it('reports unavailable on a challenge error', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await flush()
options()!['error-callback']!()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
})
it('clears the unavailable fallback once a token is solved', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await flush()
options()!['error-callback']!()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
options()!.callback!('token-abc')
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
})
it('falls back once the widget fails to resolve within the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
// Let the onMounted hook's `await loadTurnstile()` microtask settle
// and render() run, without yet advancing to the timeout itself.
await vi.advanceTimersByTimeAsync(0)
expect(options()).toBeDefined()
expect(emitted()['update:unavailable']).toBeUndefined()
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
} finally {
vi.useRealTimers()
}
})
it('does not fall back once a token arrives before the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await vi.advanceTimersByTimeAsync(0)
options()!.callback!('token-abc')
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
it('resets the widget to fetch a fresh challenge on token expiry', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
renderWidget()
await flush()
options()!.callback!('token-abc')
options()!['expired-callback']!()
await flush()
expect(api.reset).toHaveBeenCalledWith('widget-id')
})
it('falls back if a post-solve expiry is not followed by a fresh token within the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
const { emitted } = renderWidget()
await vi.advanceTimersByTimeAsync(0)
// Establish a solved, available widget: an initial error marks it
// unavailable, then solving a challenge clears that (the same
// transition the existing "clears the unavailable fallback" test
// verifies), so the expiry below is the only thing driving fallback.
options()!['error-callback']!()
options()!.callback!('token-abc')
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
// The token later expires (e.g. tab backgrounded past its ~300s
// lifetime) without the widget itself erroring.
options()!['expired-callback']!()
await vi.advanceTimersByTimeAsync(0)
// A fresh challenge was requested, but nothing solves it before the
// re-armed load timeout elapses, so submission must eventually be
// unblocked rather than staying stuck forever.
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
} finally {
vi.useRealTimers()
}
})
})
})

View File

@@ -12,7 +12,6 @@
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -21,14 +20,6 @@ import { getTurnstileSiteKey } from '@/config/turnstile'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const token = defineModel<string>('token', { default: '' })
/**
* Set true whenever the widget cannot be relied on to ever produce a token:
* the Cloudflare script failed to load, the rendered challenge errored out,
* or it simply hasn't resolved within `TURNSTILE_LOAD_TIMEOUT_MS`. The parent
* uses this to stop waiting on a token so a broken/slow widget (network
* issue, ad-blocker, CDN outage) can never permanently block signup.
*/
const unavailable = defineModel<boolean>('unavailable', { default: false })
const { t } = useI18n()
const colorPaletteStore = useColorPaletteStore()
@@ -37,16 +28,6 @@ const containerRef = ref<HTMLDivElement>()
const errorMessage = ref('')
let widgetId: string | undefined
/** How long to wait for the widget to resolve before falling back. */
const TURNSTILE_LOAD_TIMEOUT_MS = 9_000
const { start: armTimeout, stop: clearLoadTimeout } = useTimeoutFn(
() => {
unavailable.value = true
},
TURNSTILE_LOAD_TIMEOUT_MS,
{ immediate: false }
)
const clearToken = () => {
token.value = ''
}
@@ -65,18 +46,12 @@ const reset = () => {
errorMessage.value = ''
if (widgetId && window.turnstile) {
window.turnstile.reset(widgetId)
// A widget that renders can request a fresh challenge, so give it
// another chance before falling back again.
unavailable.value = false
armTimeout()
}
}
defineExpose({ reset })
onMounted(async () => {
armTimeout()
try {
const turnstile = await loadTurnstile()
if (!containerRef.value) return
@@ -89,37 +64,23 @@ onMounted(async () => {
sitekey: getTurnstileSiteKey(),
theme,
callback: (newToken: string) => {
clearLoadTimeout()
errorMessage.value = ''
unavailable.value = false
token.value = newToken
},
'expired-callback': () => {
clearToken()
errorMessage.value = t('auth.turnstile.expired')
if (widgetId && window.turnstile) {
window.turnstile.reset(widgetId)
// A solved token can expire on its own (e.g. the tab was
// backgrounded past the token's ~300s lifetime) without the widget
// ever erroring, so proactively request a fresh challenge and
// re-arm the load timeout in case it doesn't resolve in time.
armTimeout()
}
},
'error-callback': () => {
clearToken()
clearLoadTimeout()
console.warn('Turnstile challenge failed')
errorMessage.value = t('auth.turnstile.failed')
unavailable.value = true
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
}
})
} catch (error) {
clearLoadTimeout()
console.warn('Turnstile failed to load', error)
errorMessage.value = t('auth.turnstile.failed')
unavailable.value = true
}
})

View File

@@ -9,10 +9,11 @@ const PRIMEVUE_OVERLAY_SELECTORS =
// Reka portals its own dialogs / popovers / menus into the body too. When a
// nested Reka layer opens on top of a non-modal parent, the parent's
// DismissableLayer sees the focus shift / pointer-down as "outside" and would
// dismiss itself. These selectors cover the portaled roots so we can treat
// interactions on them as inside.
// dismiss itself. These selectors cover the portaled roots (and the triggers
// that toggle them — clicking a menu/popup trigger to close it must not dismiss
// the surrounding dialog) so we can treat interactions on them as inside.
const REKA_PORTAL_SELECTORS =
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"], [aria-haspopup="menu"], [aria-haspopup="dialog"], [aria-haspopup="listbox"]'
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`

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

@@ -1,239 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import type { TestingPinia } from '@pinia/testing'
import { render, screen, waitFor, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { testI18n } from '@/components/searchbox/v2/__test__/testUtils'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { fromAny } from '@total-typescript/shoehorn'
import ErrorGroupList from './ErrorGroupList.vue'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
serialize: vi.fn(() => ({})),
getNodeById: vi.fn()
}
}
}))
vi.mock('@/utils/graphTraversalUtil', () => ({
getNodeByExecutionId: vi.fn(),
getExecutionIdByNode: vi.fn(),
getRootParentNode: vi.fn(() => null),
forEachNode: vi.fn(),
mapAllNodes: vi.fn(() => [])
}))
vi.mock('@/utils/litegraphUtil', () => ({
isLGraphNode: vi.fn(() => false)
}))
vi.mock('@/composables/useCopyToClipboard', () => ({
useCopyToClipboard: vi.fn(() => ({
copyToClipboard: vi.fn()
}))
}))
vi.mock('@/composables/canvas/useFocusNode', () => ({
useFocusNode: vi.fn(() => ({
focusNode: vi.fn()
}))
}))
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
downloadModel: vi.fn(),
fetchModelMetadata: vi.fn().mockResolvedValue({
fileSize: null,
gatedRepoUrl: null
}),
isModelDownloadable: vi.fn(() => true),
toBrowsableUrl: vi.fn((url: string) => url)
}))
const SAMPLER_NODE = { id: '1', title: 'SamplerNode' }
const LOADER_NODE = { id: '2', title: 'LoaderNode' }
function seedTwoErrorGroups(pinia: TestingPinia) {
const executionErrorStore = useExecutionErrorStore(pinia)
executionErrorStore.lastNodeErrors = fromAny<
typeof executionErrorStore.lastNodeErrors,
unknown
>({
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: '',
extra_info: { input_name: 'clip' }
}
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{ type: 'weird_error', message: 'Something odd happened', details: '' }
]
}
})
}
function renderList(pinia: TestingPinia) {
const user = userEvent.setup()
render(ErrorGroupList, {
global: {
plugins: [PrimeVue, testI18n, pinia],
stubs: {
AsyncSearchInput: {
template: '<input />'
}
}
}
})
return { user }
}
function createPinia() {
return createTestingPinia({ createSpy: vi.fn, stubActions: false })
}
function getSectionByTitle(title: string) {
const sections = screen.getAllByTestId('error-group-execution')
const section = sections.find((s) => within(s).queryByText(title))
expect(section).toBeDefined()
return section!
}
function isSectionExpanded(section: HTMLElement) {
const [header] = within(section).getAllByRole('button', { hidden: true })
return header.getAttribute('aria-expanded') === 'true'
}
describe('ErrorGroupList selection emphasis', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(isLGraphNode).mockReturnValue(true)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>(
String(nodeId) === '1' ? SAMPLER_NODE : LOADER_NODE
)
)
})
it('expands matched groups, collapses others, and restores on deselect', async () => {
const pinia = createPinia()
seedTwoErrorGroups(pinia)
renderList(pinia)
const canvasStore = useCanvasStore(pinia)
const samplerSection = getSectionByTitle('Missing connection')
const loaderSection = getSectionByTitle('Validation failed')
expect(isSectionExpanded(samplerSection)).toBe(true)
expect(isSectionExpanded(loaderSection)).toBe(true)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([SAMPLER_NODE])
await waitFor(() => {
expect(isSectionExpanded(loaderSection)).toBe(false)
})
expect(isSectionExpanded(samplerSection)).toBe(true)
canvasStore.selectedItems = []
await waitFor(() => {
expect(isSectionExpanded(loaderSection)).toBe(true)
})
expect(isSectionExpanded(samplerSection)).toBe(true)
})
it('expands only matched groups for a selection that predates mount', async () => {
const pinia = createPinia()
seedTwoErrorGroups(pinia)
const canvasStore = useCanvasStore(pinia)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([SAMPLER_NODE])
renderList(pinia)
await waitFor(() => {
expect(isSectionExpanded(getSectionByTitle('Validation failed'))).toBe(
false
)
})
expect(isSectionExpanded(getSectionByTitle('Missing connection'))).toBe(
true
)
})
it('leaves manual collapse state alone for selections without errors', async () => {
const pinia = createPinia()
seedTwoErrorGroups(pinia)
const { user } = renderList(pinia)
const canvasStore = useCanvasStore(pinia)
const loaderSection = getSectionByTitle('Validation failed')
const [loaderHeader] = within(loaderSection).getAllByRole('button')
await user.click(loaderHeader)
expect(isSectionExpanded(loaderSection)).toBe(false)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '99', title: 'Unrelated' }])
await waitFor(() => {
// No emphasis: the strip falls back to the workflow summary
expect(screen.getByTestId('selection-context-strip')).toHaveTextContent(
'2 nodes — 2 errors'
)
})
expect(isSectionExpanded(loaderSection)).toBe(false)
expect(isSectionExpanded(getSectionByTitle('Missing connection'))).toBe(
true
)
})
it('always shows the strip: workflow summary by default, selection while emphasized', async () => {
const pinia = createPinia()
seedTwoErrorGroups(pinia)
renderList(pinia)
const canvasStore = useCanvasStore(pinia)
const strip = screen.getByTestId('selection-context-strip')
expect(strip).toHaveTextContent('2 nodes — 2 errors')
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([SAMPLER_NODE])
await waitFor(() => {
expect(strip).toHaveTextContent('SamplerNode — 1 error')
})
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([SAMPLER_NODE, LOADER_NODE])
await waitFor(() => {
expect(strip).toHaveTextContent('2 nodes selected — 2 errors')
})
canvasStore.selectedItems = []
await waitFor(() => {
expect(strip).toHaveTextContent('2 nodes — 2 errors')
})
})
})

View File

@@ -1,609 +0,0 @@
<template>
<div class="flex min-w-0 flex-col">
<!-- Search bar + collapse toggle -->
<div
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
<CollapseToggleButton
v-model="isAllCollapsed"
:show="!isSearching && allErrorGroups.length > 1"
/>
</div>
<div class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3">
<div
v-if="filteredGroups.length === 0"
role="status"
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
</div>
<div
v-else
class="overflow-hidden rounded-lg border border-secondary-background"
>
<!-- Errors summary hero -->
<div
data-testid="errors-summary-hero"
class="flex items-center gap-2 bg-base-foreground/5 p-2"
>
<span
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
>
{{ totalErrorCount }}
</span>
<span
aria-hidden="true"
class="h-9 w-px shrink-0 bg-interface-stroke"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
<span class="text-xs/tight font-semibold text-base-foreground">
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
</span>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.resolveBeforeRun') }}
</span>
</div>
</div>
<!-- Context strip: workflow summary, or the selection's errors -->
<div
data-testid="selection-context-strip"
role="status"
class="flex items-center border-t border-secondary-background px-3 pt-3.5 pb-1.5"
>
<i18n-t
:keypath="strip.keypath"
:plural="strip.count"
tag="span"
:class="
cn(
'min-w-0 flex-1 truncate text-xs font-semibold transition-colors duration-200',
hasSelectionEmphasis
? 'text-primary-background-hover'
: 'text-muted-foreground'
)
"
>
<template #node>{{ selectionStripNodeLabel }}</template>
<template #nodes>{{ strip.nodes }}</template>
<template #count>{{ strip.count }}</template>
</i18n-t>
</div>
<!-- Group by Class Type -->
<TransitionGroup tag="div" name="list-scale" class="relative">
<ErrorCardSection
v-for="group in filteredGroups"
:key="group.groupKey"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:title="group.displayTitle"
:count="group.count"
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
class="border-t border-secondary-background first:border-t-0"
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
>
<template #actions>
<Button
v-if="
group.type === 'missing_node' &&
missingNodePacks.length > 0 &&
shouldShowInstallButton
"
variant="secondary"
size="sm"
class="shrink-0"
:disabled="isInstallingAll"
@click.stop="installAll"
>
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
{{
isInstallingAll
? t('rightSidePanel.missingNodePacks.installing')
: t('rightSidePanel.missingNodePacks.installAll')
}}
</Button>
<Button
v-else-if="group.type === 'swap_nodes'"
v-tooltip.top="
t(
'nodeReplacement.replaceAllWarning',
'Replaces all available nodes in this group.'
)
"
variant="secondary"
size="sm"
class="shrink-0"
@click.stop="handleReplaceAll()"
>
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
</Button>
<Button
v-else-if="
group.type === 'missing_model' &&
showMissingModelHeaderRefresh
"
data-testid="missing-model-header-refresh"
variant="muted-textonly"
size="icon"
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingModels.refresh')"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click.stop="handleMissingModelRefresh"
>
<DotSpinner
v-if="missingModelStore.isRefreshingMissingModels"
aria-hidden="true"
duration="1s"
:size="12"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
</Button>
<span
v-if="
group.type === 'missing_model' &&
showMissingModelHeaderRefresh
"
role="status"
aria-live="polite"
class="sr-only"
>
{{
missingModelStore.isRefreshingMissingModels
? t('rightSidePanel.missingModels.refreshing')
: ''
}}
</span>
</template>
<div
v-if="group.displayMessage"
data-testid="error-group-display-message"
class="px-3 py-1"
>
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
>
{{ group.displayMessage }}
</p>
</div>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:missing-pack-groups="missingPackGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
/>
<!-- Swap Nodes -->
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- Execution Errors -->
<div v-if="isExecutionItemListGroup(group)" class="px-3">
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="item in getExecutionItemList(group)"
:key="item.key"
:aria-current="
isCardInSelection(item.cardId) ? 'true' : undefined
"
:class="
cn(
'min-w-0',
selectionEmphasisClass(isCardInSelection(item.cardId))
)
"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-tooltip.top="{
value: item.displayDetails || undefined,
showDelay: 300
}"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="handleLocateNode(item.nodeId)"
>
{{ item.label }}
</button>
<Button
v-if="item.displayDetails"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
isExecutionItemDetailExpanded(item.key) &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="
t('rightSidePanel.infoFor', { item: item.label })
"
:aria-controls="getExecutionItemDetailId(item.key)"
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
@click.stop="toggleExecutionItemDetail(item.key)"
>
<i class="icon-[lucide--info] size-3.5" />
</Button>
</span>
<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
})
"
@click.stop="handleLocateNode(item.nodeId)"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<p
v-if="
item.displayDetails &&
isExecutionItemDetailExpanded(item.key)
"
:id="getExecutionItemDetailId(item.key)"
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ item.displayDetails }}
</p>
</TransitionCollapse>
</li>
</ul>
</div>
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:aria-current="isCardInSelection(card.id) ? 'true' : undefined"
:class="
cn(
selectionEmphasisClass(isCardInSelection(card.id)),
isCardInSelection(card.id) && '-my-1 py-1'
)
"
@locate-node="handleLocateNode"
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Missing Models -->
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-model="handleLocateAssetNode"
/>
<!-- Missing Media -->
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
:highlighted-node-ids="selectionMatchedAssetNodeIds"
@locate-node="handleLocateAssetNode"
/>
</ErrorCardSection>
</TransitionGroup>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
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 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 { 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 { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
import { isExecutionItemListGroup } from './executionItemList'
import { selectionEmphasisClass } from './selectionEmphasis'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
interface ExecutionItemListEntry {
key: string
cardId: string
nodeId: string
label: string
displayDetails?: string
}
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode } = useFocusNode()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
useManagerState()
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
const expandedExecutionItemDetailKeys = ref(new Set<string>())
const isSearching = computed(() => searchQuery.value.trim() !== '')
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
if (group.type !== 'execution') return []
const items: ExecutionItemListEntry[] = []
for (const card of group.cards) {
if (!card.nodeId) continue
for (let idx = 0; idx < card.errors.length; idx++) {
const error = card.errors[idx]
const label = error.displayItemLabel
if (!label) continue
items.push({
key: `${card.id}:${idx}`,
cardId: card.id,
nodeId: card.nodeId,
label,
displayDetails: error.displayDetails
})
}
}
return items.sort(compareExecutionItemListEntry)
}
function compareExecutionItemListEntry(
a: ExecutionItemListEntry,
b: ExecutionItemListEntry
) {
return (
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
a.label.localeCompare(b.label)
)
}
function isExecutionItemDetailExpanded(key: string) {
return expandedExecutionItemDetailKeys.value.has(key)
}
function toggleExecutionItemDetail(key: string) {
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
if (nextKeys.has(key)) {
nextKeys.delete(key)
} else {
nextKeys.add(key)
}
expandedExecutionItemDetailKeys.value = nextKeys
}
function getExecutionItemDetailId(key: string) {
return `execution-item-detail-${key}`
}
const {
allErrorGroups,
filteredGroups,
collapseState,
errorNodeCache,
missingNodeCache,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups,
hasSelection,
selectedNodeCount,
selectedNodeTitle,
selectionMatchedGroupKeys,
selectionMatchedCardIds,
selectionMatchedAssetNodeIds,
selectionErrorCount,
errorNodeCount
} = useErrorGroups(searchQuery)
const totalErrorCount = computed(() =>
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
)
const hasSelectionEmphasis = computed(
() => hasSelection.value && selectionErrorCount.value > 0
)
const selectionStripNodeLabel = computed(
() => selectedNodeTitle.value ?? t('g.untitled')
)
// The strip is a status line, not a view of the current filter — summary
// numbers are workflow-wide, never search-filtered.
const workflowErrorCount = computed(() =>
allErrorGroups.value.reduce((sum, group) => sum + group.count, 0)
)
const strip = computed(() => {
if (hasSelectionEmphasis.value) {
return {
keypath:
selectedNodeCount.value === 1
? 'rightSidePanel.selectedNodeErrors'
: 'rightSidePanel.selectedNodesErrors',
nodes: selectedNodeCount.value,
count: selectionErrorCount.value
}
}
return {
keypath:
errorNodeCount.value === 0
? // Node-less errors (e.g. prompt-level) would read as "0 nodes"
'rightSidePanel.errorsSummary'
: errorNodeCount.value === 1
? 'rightSidePanel.errorNodeSummary'
: 'rightSidePanel.errorNodesSummary',
nodes: errorNodeCount.value,
count: workflowErrorCount.value
}
})
function isCardInSelection(cardId: string): boolean {
return selectionMatchedCardIds.value.has(cardId)
}
/**
* Dedupes the Set-valued computed (fresh reference per recompute) so the
* emphasis watcher below only fires when the matched membership changes.
*/
const selectionEmphasisSignature = computed(() =>
hasSelection.value
? Array.from(selectionMatchedGroupKeys.value).sort().join('\n')
: ''
)
/**
* Selection acts as emphasis, not a filter: expand the groups containing
* the selected nodes' errors and collapse the rest. When the emphasis ends
* (selection cleared or moved to a node without errors), re-expand all
* groups so the tab reads as the workflow overview again.
*/
watch(
selectionEmphasisSignature,
(signature, previousSignature) => {
if (!signature) {
if (!previousSignature) return
for (const groupKey of Object.keys(collapseState)) {
setSectionCollapsed(groupKey, false)
}
return
}
const matchedKeys = selectionMatchedGroupKeys.value
for (const group of allErrorGroups.value) {
setSectionCollapsed(group.groupKey, !matchedKeys.has(group.groupKey))
}
},
{ immediate: true }
)
const showMissingModelHeaderRefresh = computed(
() => !isCloud && missingModelGroups.value.length > 0
)
function handleMissingModelRefresh() {
if (missingModelStore.isRefreshingMissingModels) return
void missingModelStore.refreshMissingModels()
}
const isAllCollapsed = computed({
get() {
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
},
set(collapse: boolean) {
for (const group of allErrorGroups.value) {
setSectionCollapsed(group.groupKey, collapse)
}
}
})
function isSectionCollapsed(groupKey: string): boolean {
// Defaults to expanded when not explicitly set by the user
return collapseState[groupKey] ?? false
}
function setSectionCollapsed(groupKey: string, collapsed: boolean) {
collapseState[groupKey] = collapsed
}
/**
* When an external trigger (e.g. "See Error" button in SectionWidgets)
* sets focusedErrorNodeId, expand only the group containing the target
* node and collapse all others so the user sees the relevant errors
* immediately.
*/
watch(
() => rightSidePanelStore.focusedErrorNodeId,
(graphNodeId) => {
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.groupKey, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
},
{ immediate: true }
)
function handleLocateNode(nodeId: string) {
focusNode(nodeId, errorNodeCache.value)
}
function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleLocateAssetNode(nodeId: string) {
focusNode(nodeId)
}
function handleOpenManagerInfo(packId: string) {
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
if (isKnownToRegistry) {
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
} else {
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
}
}
function handleReplaceGroup(group: SwapNodeGroup) {
replaceGroup(group)
}
function handleReplaceAll() {
replaceAllGroups(swapNodeGroups.value)
}
</script>

View File

@@ -1,6 +1,9 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
<div v-if="card.nodeId" class="flex min-h-8 flex-wrap items-center gap-2">
<div
v-if="card.nodeId && !compact"
class="flex min-h-8 flex-wrap items-center gap-2"
>
<span class="flex min-w-0 flex-1">
<button
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
@@ -100,7 +103,7 @@
<TransitionCollapse>
<div
v-if="error.isRuntimeError && runtimeDetailsExpanded"
v-if="error.isRuntimeError && isRuntimeDisclosureExpanded"
:id="getRuntimeDetailsId(idx)"
role="region"
data-testid="runtime-error-panel"
@@ -183,8 +186,9 @@ import type { ErrorCardData, ErrorItem } from './types'
import { useErrorActions } from './useErrorActions'
import { useErrorReport } from './useErrorReport'
const { card } = defineProps<{
const { card, compact = false } = defineProps<{
card: ErrorCardData
compact?: boolean
}>()
const emit = defineEmits<{
@@ -199,6 +203,9 @@ const runtimeDetailsExpanded = ref(true)
const hasRuntimeError = computed(() =>
card.errors.some((error) => error.isRuntimeError)
)
const isRuntimeDisclosureExpanded = computed(
() => compact || runtimeDetailsExpanded.value
)
const runtimeDetailsControlIds = computed(() =>
card.errors
.map((error, idx) => (error.isRuntimeError ? getRuntimeDetailsId(idx) : ''))

View File

@@ -56,15 +56,12 @@
>
</template>
</i18n-t>
<div class="-mx-1.5 flex flex-col gap-1 overflow-hidden px-1.5">
<div class="flex flex-col gap-1 overflow-hidden">
<MissingPackGroupRow
v-for="group in missingPackGroups"
:key="group.packId ?? '__unknown__'"
:group="group"
:show-info-button="showInfoButton"
:highlighted="
someNodeTypeInSelection(group.nodeTypes, highlightedNodeIds)
"
@locate-node="emit('locateNode', $event)"
@open-manager-info="emit('openManagerInfo', $event)"
/>
@@ -109,13 +106,10 @@ import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
import MissingPackGroupRow from '@/components/rightSidePanel/errors/MissingPackGroupRow.vue'
import { someNodeTypeInSelection } from '@/components/rightSidePanel/errors/selectionEmphasis'
const { showInfoButton, missingPackGroups } = defineProps<{
showInfoButton: boolean
missingPackGroups: MissingPackGroup[]
/** Execution node ids to emphasize (current canvas selection). */
highlightedNodeIds?: Set<string>
}>()
const emit = defineEmits<{

View File

@@ -1,14 +1,6 @@
<template>
<div class="mb-1 flex w-full flex-col gap-0.5 last:mb-0">
<div
:aria-current="highlighted ? 'true' : undefined"
:class="
cn(
'flex min-h-8 items-center gap-1',
selectionEmphasisClass(highlighted)
)
"
>
<div class="flex min-h-8 w-full items-center gap-1">
<Button
v-if="hasMultipleNodeTypes"
data-testid="missing-node-pack-expand"
@@ -224,8 +216,6 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
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 TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
@@ -237,11 +227,9 @@ import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTyp
import type { MissingNodeType } from '@/types/comfy'
import type { MissingPackGroup } from '@/components/rightSidePanel/errors/useErrorGroups'
const { group, showInfoButton, highlighted } = defineProps<{
const { group, showInfoButton } = defineProps<{
group: MissingPackGroup
showInfoButton: boolean
/** Emphasize the header row (pack containing the canvas selection). */
highlighted?: boolean
}>()
const emit = defineEmits<{

View File

@@ -1,6 +1,275 @@
<template>
<div class="flex h-full min-w-0 flex-col">
<ErrorGroupList class="min-h-0 flex-1" />
<!-- Search bar + collapse toggle -->
<div
class="flex min-w-0 shrink-0 items-center border-b border-interface-stroke px-4 pt-1 pb-4"
>
<AsyncSearchInput v-model="searchQuery" class="flex-1" />
<CollapseToggleButton
v-model="isAllCollapsed"
:show="!isSearching && tabErrorGroups.length > 1"
/>
</div>
<div
class="min-w-0 flex-1 overflow-y-auto bg-interface-panel-surface p-3"
aria-live="polite"
>
<div
v-if="filteredGroups.length === 0"
class="px-1 pt-5 pb-15 text-center text-sm text-muted-foreground"
>
{{
searchQuery.trim()
? t('rightSidePanel.noneSearchDesc')
: t('rightSidePanel.noErrors')
}}
</div>
<div
v-else
class="overflow-hidden rounded-lg border border-secondary-background"
>
<!-- Errors summary hero -->
<div
data-testid="errors-summary-hero"
class="flex items-center gap-2 bg-base-foreground/5 p-2"
>
<span
class="flex h-12 min-w-9 shrink-0 items-center justify-center px-1 text-[2rem]/none font-extrabold text-destructive-background-hover tabular-nums"
>
{{ totalErrorCount }}
</span>
<span
aria-hidden="true"
class="h-9 w-px shrink-0 bg-interface-stroke"
/>
<div class="flex min-w-0 flex-1 flex-col gap-1 px-2">
<span class="text-xs/tight font-semibold text-base-foreground">
{{ t('rightSidePanel.errorsDetected', totalErrorCount) }}
</span>
<span class="text-xs/tight text-muted-foreground">
{{ t('rightSidePanel.resolveBeforeRun') }}
</span>
</div>
</div>
<!-- Group by Class Type -->
<TransitionGroup tag="div" name="list-scale" class="relative">
<ErrorCardSection
v-for="group in filteredGroups"
:key="group.groupKey"
:data-testid="'error-group-' + group.type.replaceAll('_', '-')"
:title="group.displayTitle"
:count="group.count"
:collapse="isSectionCollapsed(group.groupKey) && !isSearching"
class="border-t border-secondary-background first:border-t-0"
@update:collapse="setSectionCollapsed(group.groupKey, $event)"
>
<template #actions>
<Button
v-if="
group.type === 'missing_node' &&
missingNodePacks.length > 0 &&
shouldShowInstallButton
"
variant="secondary"
size="sm"
class="shrink-0"
:disabled="isInstallingAll"
@click.stop="installAll"
>
<DotSpinner v-if="isInstallingAll" duration="1s" :size="12" />
{{
isInstallingAll
? t('rightSidePanel.missingNodePacks.installing')
: t('rightSidePanel.missingNodePacks.installAll')
}}
</Button>
<Button
v-else-if="group.type === 'swap_nodes'"
v-tooltip.top="
t(
'nodeReplacement.replaceAllWarning',
'Replaces all available nodes in this group.'
)
"
variant="secondary"
size="sm"
class="shrink-0"
@click.stop="handleReplaceAll()"
>
{{ t('nodeReplacement.replaceAll', 'Replace All') }}
</Button>
<Button
v-else-if="
group.type === 'missing_model' &&
showMissingModelHeaderRefresh
"
data-testid="missing-model-header-refresh"
variant="muted-textonly"
size="icon"
class="shrink-0 rounded-lg hover:bg-transparent hover:text-base-foreground"
:aria-label="t('rightSidePanel.missingModels.refresh')"
:aria-busy="missingModelStore.isRefreshingMissingModels"
:aria-disabled="missingModelStore.isRefreshingMissingModels"
@click.stop="handleMissingModelRefresh"
>
<DotSpinner
v-if="missingModelStore.isRefreshingMissingModels"
aria-hidden="true"
duration="1s"
:size="12"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--refresh-cw] size-4 shrink-0"
/>
</Button>
<span
v-if="
group.type === 'missing_model' &&
showMissingModelHeaderRefresh
"
role="status"
aria-live="polite"
class="sr-only"
>
{{
missingModelStore.isRefreshingMissingModels
? t('rightSidePanel.missingModels.refreshing')
: ''
}}
</span>
</template>
<div
v-if="group.displayMessage"
data-testid="error-group-display-message"
class="px-3 py-1"
>
<p
class="m-0 text-xs/normal wrap-break-word whitespace-pre-wrap text-base-foreground/50"
>
{{ group.displayMessage }}
</p>
</div>
<!-- Missing Node Packs -->
<MissingNodeCard
v-if="group.type === 'missing_node'"
:show-info-button="shouldShowManagerButtons"
:missing-pack-groups="missingPackGroups"
@locate-node="handleLocateMissingNode"
@open-manager-info="handleOpenManagerInfo"
/>
<!-- Swap Nodes -->
<SwapNodesCard
v-if="group.type === 'swap_nodes'"
:swap-node-groups="swapNodeGroups"
@locate-node="handleLocateMissingNode"
@replace="handleReplaceGroup"
/>
<!-- Execution Errors -->
<div v-if="isExecutionItemListGroup(group)" class="px-3">
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="item in getExecutionItemList(group)"
:key="item.key"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-tooltip.top="{
value: item.displayDetails || undefined,
showDelay: 300
}"
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm border-0 bg-transparent p-0 text-left text-xs/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:ring-1 focus-visible:outline-none focus-visible:ring-inset"
@click="handleLocateNode(item.nodeId)"
>
{{ item.label }}
</button>
<Button
v-if="item.displayDetails"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground focus-visible:ring-inset',
isExecutionItemDetailExpanded(item.key) &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="
t('rightSidePanel.infoFor', { item: item.label })
"
:aria-controls="getExecutionItemDetailId(item.key)"
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
@click.stop="toggleExecutionItemDetail(item.key)"
>
<i class="icon-[lucide--info] size-3.5" />
</Button>
</span>
<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 })
"
@click.stop="handleLocateNode(item.nodeId)"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<p
v-if="
item.displayDetails &&
isExecutionItemDetailExpanded(item.key)
"
:id="getExecutionItemDetailId(item.key)"
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ item.displayDetails }}
</p>
</TransitionCollapse>
</li>
</ul>
</div>
<div v-else-if="group.type === 'execution'" class="space-y-3 px-3">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Missing Models -->
<MissingModelCard
v-if="group.type === 'missing_model'"
:missing-model-groups="missingModelGroups"
@locate-model="handleLocateAssetNode"
/>
<!-- Missing Media -->
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
@locate-node="handleLocateAssetNode"
/>
</ErrorCardSection>
</TransitionGroup>
</div>
</div>
<ErrorPanelSurveyCta v-if="ErrorPanelSurveyCta" />
@@ -39,14 +308,44 @@
</template>
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useManagerState } from '@/workbench/extensions/manager/composables/useManagerState'
import { ManagerTab } from '@/workbench/extensions/manager/types/comfyManagerTypes'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
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 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 { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import ErrorGroupList from './ErrorGroupList.vue'
import Button from '@/components/ui/button/Button.vue'
import DotSpinner from '@/components/common/DotSpinner.vue'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { usePackInstall } from '@/workbench/extensions/manager/composables/nodePack/usePackInstall'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { useErrorActions } from './useErrorActions'
import { useErrorGroups } from './useErrorGroups'
import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
import { isExecutionItemListGroup } from './executionItemList'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
interface ExecutionItemListEntry {
key: string
nodeId: string
label: string
displayDetails?: string
}
const ErrorPanelSurveyCta =
isNightly && !isCloud && !isDesktop
@@ -56,5 +355,171 @@ const ErrorPanelSurveyCta =
: undefined
const { t } = useI18n()
const { copyToClipboard } = useCopyToClipboard()
const { focusNode } = useFocusNode()
const { openGitHubIssues, contactSupport } = useErrorActions()
const rightSidePanelStore = useRightSidePanelStore()
const missingModelStore = useMissingModelStore()
const { shouldShowManagerButtons, shouldShowInstallButton, openManager } =
useManagerState()
const { missingNodePacks } = useMissingNodes()
const { isInstalling: isInstallingAll, installAllPacks: installAll } =
usePackInstall(() => missingNodePacks.value)
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
const expandedExecutionItemDetailKeys = ref(new Set<string>())
const isSearching = computed(() => searchQuery.value.trim() !== '')
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
if (group.type !== 'execution') return []
const items: ExecutionItemListEntry[] = []
for (const card of group.cards) {
if (!card.nodeId) continue
for (let idx = 0; idx < card.errors.length; idx++) {
const error = card.errors[idx]
const label = error.displayItemLabel
if (!label) continue
items.push({
key: `${card.id}:${idx}`,
nodeId: card.nodeId,
label,
displayDetails: error.displayDetails
})
}
}
return items.sort(compareExecutionItemListEntry)
}
function compareExecutionItemListEntry(
a: ExecutionItemListEntry,
b: ExecutionItemListEntry
) {
return (
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
a.label.localeCompare(b.label)
)
}
function isExecutionItemDetailExpanded(key: string) {
return expandedExecutionItemDetailKeys.value.has(key)
}
function toggleExecutionItemDetail(key: string) {
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
if (nextKeys.has(key)) {
nextKeys.delete(key)
} else {
nextKeys.add(key)
}
expandedExecutionItemDetailKeys.value = nextKeys
}
function getExecutionItemDetailId(key: string) {
return `execution-item-detail-${key}`
}
const {
allErrorGroups,
tabErrorGroups,
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache,
missingNodeCache,
missingPackGroups,
filteredMissingModelGroups: missingModelGroups,
filteredMissingMediaGroups: missingMediaGroups,
swapNodeGroups
} = useErrorGroups(searchQuery)
const totalErrorCount = computed(() =>
filteredGroups.value.reduce((sum, group) => sum + group.count, 0)
)
const showMissingModelHeaderRefresh = computed(
() => !isCloud && missingModelGroups.value.length > 0
)
function handleMissingModelRefresh() {
if (missingModelStore.isRefreshingMissingModels) return
void missingModelStore.refreshMissingModels()
}
const isAllCollapsed = computed({
get() {
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))
},
set(collapse: boolean) {
for (const group of tabErrorGroups.value) {
setSectionCollapsed(group.groupKey, collapse)
}
}
})
function isSectionCollapsed(groupKey: string): boolean {
// Defaults to expanded when not explicitly set by the user
return collapseState[groupKey] ?? false
}
function setSectionCollapsed(groupKey: string, collapsed: boolean) {
collapseState[groupKey] = collapsed
}
/**
* When an external trigger (e.g. "See Error" button in SectionWidgets)
* sets focusedErrorNodeId, expand only the group containing the target
* node and collapse all others so the user sees the relevant errors
* immediately.
*/
watch(
() => rightSidePanelStore.focusedErrorNodeId,
(graphNodeId) => {
if (!graphNodeId) return
const prefix = `${graphNodeId}:`
for (const group of allErrorGroups.value) {
if (group.type !== 'execution') continue
const hasMatch = group.cards.some(
(card) =>
card.graphNodeId === graphNodeId ||
(card.nodeId?.startsWith(prefix) ?? false)
)
setSectionCollapsed(group.groupKey, !hasMatch)
}
rightSidePanelStore.focusedErrorNodeId = null
},
{ immediate: true }
)
function handleLocateNode(nodeId: string) {
focusNode(nodeId, errorNodeCache.value)
}
function handleLocateMissingNode(nodeId: string) {
focusNode(nodeId, missingNodeCache.value)
}
function handleLocateAssetNode(nodeId: string) {
focusNode(nodeId)
}
function handleOpenManagerInfo(packId: string) {
const isKnownToRegistry = missingNodePacks.value.some((p) => p.id === packId)
if (isKnownToRegistry) {
openManager({ initialTab: ManagerTab.Missing, initialPackId: packId })
} else {
openManager({ initialTab: ManagerTab.All, initialPackId: packId })
}
}
function handleReplaceGroup(group: SwapNodeGroup) {
replaceGroup(group)
}
function handleReplaceAll() {
replaceAllGroups(swapNodeGroups.value)
}
</script>

View File

@@ -1,30 +0,0 @@
import { cn } from '@comfyorg/tailwind-utils'
import type { MissingNodeType } from '@/types/comfy'
// The negative margin and matching padding cancel out, so the background
// bleeds 6px past the content without shifting the text.
const EMPHASIS_CLASS = 'rounded-sm bg-blue-selection -mx-1.5 px-1.5'
// Present even when unhighlighted so the emphasis animates both ways.
const TRANSITION_CLASS =
'transition-[background-color,margin,padding,border-radius] duration-200'
/** Classes emphasizing rows/cards that belong to the canvas selection. */
export function selectionEmphasisClass(highlighted: boolean | undefined) {
return cn(TRANSITION_CLASS, highlighted && EMPHASIS_CLASS)
}
/** True when any node type resolves to a node in the given id set. */
export function someNodeTypeInSelection(
nodeTypes: MissingNodeType[],
nodeIds: Set<string> | undefined
): boolean {
if (!nodeIds?.size) return false
return nodeTypes.some(
(nodeType) =>
typeof nodeType !== 'string' &&
nodeType.nodeId != null &&
nodeIds.has(String(nodeType.nodeId))
)
}

View File

@@ -4,7 +4,6 @@ import { nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { MissingNodeType } from '@/types/comfy'
import type { NodeExecutionId } from '@/types/nodeIdentification'
vi.mock('@/scripts/app', () => ({
app: {
@@ -127,12 +126,6 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import {
getExecutionIdByNode,
getNodeByExecutionId
} from '@/utils/graphTraversalUtil'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useErrorGroups } from './useErrorGroups'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
@@ -212,7 +205,6 @@ describe('useErrorGroups', () => {
setActivePinia(createPinia())
mockIsCloud.value = false
vi.mocked(isLGraphNode).mockReturnValue(false)
vi.mocked(getNodeByExecutionId).mockReset()
})
describe('missingPackGroups', () => {
@@ -994,13 +986,24 @@ describe('useErrorGroups', () => {
})
})
describe('selection does not shrink displayed groups', () => {
it('missingModelGroups returns total candidates regardless of selection', async () => {
describe('unfiltered vs selection-filtered model/media groups', () => {
it('exposes both unfiltered (missingModelGroups) and filtered (filteredMissingModelGroups)', () => {
const { groups } = createErrorGroups()
expect(groups.missingModelGroups).toBeDefined()
expect(groups.filteredMissingModelGroups).toBeDefined()
expect(groups.missingMediaGroups).toBeDefined()
expect(groups.filteredMissingMediaGroups).toBeDefined()
})
it('missingModelGroups returns total candidates regardless of selection (ErrorOverlay contract)', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingModels([
makeModel('a.safetensors', { nodeId: '1', directory: 'checkpoints' }),
makeModel('b.safetensors', { nodeId: '2', directory: 'checkpoints' })
])
// Simulate canvas selection of a single node so the filtered
// variant actually narrows. Without this, both sides return the
// same value trivially and the test can't prove the contract.
vi.mocked(isLGraphNode).mockReturnValue(true)
const canvasStore = useCanvasStore()
canvasStore.selectedItems = fromAny<
@@ -1009,18 +1012,23 @@ describe('useErrorGroups', () => {
>([{ id: '1' }])
await nextTick()
// Displayed groups never shrink with canvas selection — the count
// and list always describe the whole workflow.
// Unfiltered total stays at one group of two models regardless of
// the selection — ErrorOverlay reads this for the overlay label
// and must not shrink with canvas selection.
expect(groups.missingModelGroups.value).toHaveLength(1)
expect(groups.missingModelGroups.value[0].models).toHaveLength(2)
expect(
groups.filteredGroups.value.find((g) => g.type === 'missing_model')
?.count
).toBe(2)
})
})
describe('missing media counting', () => {
// Filtered variant does narrow under the same selection state —
// this is how the errors tab scopes cards to the selected node.
// Exact filtered output depends on the app.rootGraph lookup
// (mocked to return undefined here); what matters is that the
// filtered shape is a different reference and does not blindly
// mirror the unfiltered one.
expect(groups.filteredMissingModelGroups.value).not.toBe(
groups.missingModelGroups.value
)
})
it('counts missing media by affected node rows, not grouped filenames', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingMedia([
@@ -1043,8 +1051,8 @@ describe('useErrorGroups', () => {
})
})
describe('selection emphasis', () => {
it('never marks workflow-level prompt errors as matched by a selection', async () => {
describe('tabErrorGroups', () => {
it('filters prompt error when a node is selected', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
@@ -1059,205 +1067,11 @@ describe('useErrorGroups', () => {
}
await nextTick()
const promptGroup = groups.allErrorGroups.value.find(
const promptGroup = groups.tabErrorGroups.value.find(
(g) =>
g.type === 'execution' && g.displayTitle === 'Prompt has no outputs'
)
expect(promptGroup).toBeDefined()
expect(
groups.selectionMatchedGroupKeys.value.has(promptGroup!.groupKey)
).toBe(false)
})
it('reports no selection state when nothing is selected', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
await nextTick()
expect(groups.hasSelection.value).toBe(false)
expect(groups.selectionMatchedGroupKeys.value.size).toBe(0)
expect(groups.selectionMatchedCardIds.value.size).toBe(0)
expect(groups.selectionErrorCount.value).toBe(0)
})
it('matches groups and cards of the selected error node', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
const selectedNode = { id: '1' }
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>(
String(nodeId) === '1' ? selectedNode : { id: String(nodeId) }
)
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([selectedNode])
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{ type: 'file_not_found', message: 'File not found', details: '' }
]
}
}
await nextTick()
expect(groups.hasSelection.value).toBe(true)
expect(groups.selectionErrorCount.value).toBe(1)
expect(groups.selectionMatchedCardIds.value.has('node-1')).toBe(true)
expect(groups.selectionMatchedCardIds.value.has('node-2')).toBe(false)
expect(groups.selectionMatchedAssetNodeIds.value.size).toBe(0)
// Both error groups remain displayed regardless of the selection
const executionGroups = groups.filteredGroups.value.filter(
(g) => g.type === 'execution'
)
const displayedCardIds = executionGroups.flatMap((g) =>
g.type === 'execution' ? g.cards.map((c) => c.id) : []
)
expect(displayedCardIds).toContain('node-1')
expect(displayedCardIds).toContain('node-2')
})
it('narrows missing-node emphasis to packs containing the selected node', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>({ id: String(nodeId) })
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '2' }])
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' }),
makeMissingNodeType('NodeC', { cnrId: 'pack-2', nodeId: '3' })
])
await nextTick()
// Emphasis counts only the packs containing the selected node…
expect(groups.selectionMatchedGroupKeys.value.has('missing_node')).toBe(
true
)
expect(groups.selectionErrorCount.value).toBe(1)
// …and marks only the selected node for row highlighting.
expect(groups.selectionMatchedAssetNodeIds.value.has('2')).toBe(true)
expect(groups.selectionMatchedAssetNodeIds.value.has('3')).toBe(false)
// Display still shows every pack.
const missingNodeGroup = groups.filteredGroups.value.find(
(g) => g.type === 'missing_node'
)
expect(missingNodeGroup?.count).toBe(2)
})
it('does not emphasize missing-node groups for unrelated selections', async () => {
const { groups } = createErrorGroups()
const missingNodesStore = useMissingNodesErrorStore()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>({ id: String(nodeId) })
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([{ id: '99' }])
missingNodesStore.setMissingNodeTypes([
makeMissingNodeType('NodeB', { cnrId: 'pack-1', nodeId: '2' })
])
await nextTick()
expect(groups.selectionMatchedGroupKeys.value.has('missing_node')).toBe(
false
)
expect(groups.selectionErrorCount.value).toBe(0)
// Display is unaffected by the unrelated selection.
expect(
groups.filteredGroups.value.find((g) => g.type === 'missing_node')
?.count
).toBe(1)
})
it('matches errors through graph resolution, not raw execution ids', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
// The error is keyed by a subgraph execution id ('2:5') that resolves
// to a different graph node id ('7') at the current graph level.
const selectedNode = { id: '7' }
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) =>
fromAny<LGraphNode, unknown>(
String(nodeId) === '2:5' ? selectedNode : undefined
)
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([selectedNode])
store.lastNodeErrors = {
'2:5': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
}
}
await nextTick()
expect(groups.selectionErrorCount.value).toBe(1)
expect(groups.selectionMatchedCardIds.value.has('node-2:5')).toBe(true)
})
it('matches interior errors when a subgraph container is selected', async () => {
const { store, groups } = createErrorGroups()
const canvasStore = useCanvasStore()
vi.mocked(isLGraphNode).mockReturnValue(true)
// A container selection matches interior errors by execution-id prefix,
// even when the interior node does not resolve at the current level.
const containerNode = fromAny<SubgraphNode, unknown>(
Object.assign(Object.create(SubgraphNode.prototype), { id: '2' })
)
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
vi.mocked(getExecutionIdByNode).mockReturnValue(
fromAny<NodeExecutionId, unknown>('2')
)
canvasStore.selectedItems = fromAny<
typeof canvasStore.selectedItems,
unknown
>([containerNode])
store.lastNodeErrors = {
'2:5': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [{ type: 'value_error', message: 'Bad value', details: '' }]
},
'9': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{ type: 'file_not_found', message: 'File not found', details: '' }
]
}
}
await nextTick()
expect(groups.selectionErrorCount.value).toBe(1)
expect(groups.selectionMatchedCardIds.value.has('node-2:5')).toBe(true)
expect(groups.selectionMatchedCardIds.value.has('node-9')).toBe(false)
expect(promptGroup).toBeUndefined()
})
})
})

View File

@@ -24,7 +24,6 @@ import { st } from '@/i18n'
import type { MissingNodeType } from '@/types/comfy'
import type { ErrorCardData, ErrorGroup, ErrorItem } from './types'
import { shouldRenderExecutionItemList } from './executionItemList'
import { someNodeTypeInSelection } from './selectionEmphasis'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { MissingModelGroup } from '@/platform/missingModel/types'
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
@@ -260,25 +259,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
}
})
const hasSelection = computed(() => selectedNodeInfo.value.nodeIds !== null)
const selectedNodeCount = computed(
() => selectedNodeInfo.value.nodeIds?.size ?? 0
const isSingleNodeSelected = computed(
() =>
selectedNodeInfo.value.nodeIds?.size === 1 &&
selectedNodeInfo.value.containerExecutionIds.size === 0
)
const selectedNodeTitle = computed(() => {
if (selectedNodeCount.value !== 1) return null
const node = canvasStore.selectedItems.find(isLGraphNode)
if (!node) return null
return (
resolveNodeDisplayName(node, {
emptyLabel: '',
untitledLabel: '',
st
}) || null
)
})
const errorNodeCache = computed(() => {
const map = new Map<string, LGraphNode>()
for (const execId of executionErrorStore.allErrorExecutionIds) {
@@ -595,50 +581,38 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return Array.from(map.values()).sort((a, b) => a.type.localeCompare(b.type))
})
/**
* Builds ErrorGroups from missingNodesError. Returns [] when none present.
* `includeGroup` narrows which swap/pack groups are counted (used to scope
* emphasis to the canvas selection); groups reduced to zero are omitted.
*/
function buildMissingNodeGroups(
includeGroup: (nodeTypes: MissingNodeType[]) => boolean = () => true
): ErrorGroup[] {
/** Builds an ErrorGroup from missingNodesError. Returns [] when none present. */
function buildMissingNodeGroups(): ErrorGroup[] {
const error = missingNodesStore.missingNodesError
if (!error) return []
const groups: ErrorGroup[] = []
const swapCount = swapNodeGroups.value.filter((group) =>
includeGroup(group.nodeTypes)
).length
const packCount = missingPackGroups.value.filter((group) =>
includeGroup(group.nodeTypes)
).length
if (swapCount > 0) {
if (swapNodeGroups.value.length > 0) {
groups.push({
type: 'swap_nodes' as const,
groupKey: 'swap_nodes',
count: swapCount,
count: swapNodeGroups.value.length,
priority: 0,
...resolveMissingErrorMessage({
kind: 'swap_nodes',
nodeTypes: error.nodeTypes,
count: swapCount,
nodeTypes: missingNodesStore.missingNodesError?.nodeTypes ?? [],
count: swapNodeGroups.value.length,
isCloud
})
})
}
if (packCount > 0) {
if (missingPackGroups.value.length > 0) {
groups.push({
type: 'missing_node' as const,
groupKey: 'missing_node',
count: packCount,
count: missingPackGroups.value.length,
priority: 1,
...resolveMissingErrorMessage({
kind: 'missing_node',
nodeTypes: error.nodeTypes,
count: packCount,
count: missingPackGroups.value.length,
isCloud
})
})
@@ -725,33 +699,31 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
}
/** Model groups narrowed to the selection, for emphasis derivation only. */
const missingModelGroupsForSelection = computed(() => {
if (!hasSelection.value) return []
const filteredMissingModelGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
const candidates = missingModelStore.missingModelCandidates
if (!candidates?.length) return []
const matched = candidates.filter(
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
)
if (!matched.length) return []
return groupMissingModelCandidates(matched, isCloud)
if (!filtered.length) return []
return groupMissingModelCandidates(filtered, isCloud)
})
/** Media groups narrowed to the selection, for emphasis derivation only. */
const missingMediaGroupsForSelection = computed(() => {
if (!hasSelection.value) return []
const filteredMissingMediaGroups = computed(() => {
if (!selectedNodeInfo.value.nodeIds) return missingMediaGroups.value
const candidates = missingMediaStore.missingMediaCandidates
if (!candidates?.length) return []
const matched = candidates.filter(
const filtered = candidates.filter(
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
)
if (!matched.length) return []
return groupCandidatesByMediaType(matched)
if (!filtered.length) return []
return groupCandidatesByMediaType(filtered)
})
function buildMissingModelGroupsForSelection(): ErrorGroup[] {
if (!missingModelGroupsForSelection.value.length) return []
const count = countMissingModels(missingModelGroupsForSelection.value)
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingModelGroups.value.length) return []
const count = countMissingModels(filteredMissingModelGroups.value)
return [
{
type: 'missing_model' as const,
@@ -760,7 +732,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
priority: 2,
...resolveMissingErrorMessage({
kind: 'missing_model',
groups: missingModelGroupsForSelection.value,
groups: filteredMissingModelGroups.value,
count,
isCloud
})
@@ -768,10 +740,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
]
}
function buildMissingMediaGroupsForSelection(): ErrorGroup[] {
if (!missingMediaGroupsForSelection.value.length) return []
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingMediaGroups.value.length) return []
const totalRows = countMissingMediaReferences(
missingMediaGroupsForSelection.value
filteredMissingMediaGroups.value
)
return [
{
@@ -781,7 +753,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
priority: 3,
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: missingMediaGroupsForSelection.value,
groups: filteredMissingMediaGroups.value,
count: totalRows,
isCloud
})
@@ -804,113 +776,47 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
]
})
/**
* The subset of error groups whose errors belong to the current canvas
* selection. Empty when nothing is selected. Display always shows all
* groups; this subset only drives selection emphasis (auto-expand, card
* highlight, context strip).
*/
const selectionScopedGroups = computed<ErrorGroup[]>(() => {
if (!hasSelection.value) return []
const tabErrorGroups = computed<ErrorGroup[]>(() => {
const groupsMap = new Map<string, GroupEntry>()
processPromptError(groupsMap, true)
processNodeErrors(groupsMap, true)
processExecutionError(groupsMap, true)
const filterByNode = selectedNodeInfo.value.nodeIds !== null
// Missing nodes are intentionally unfiltered — they represent
// pack-level problems relevant regardless of which node is selected.
return [
...buildMissingNodeGroups((nodeTypes) =>
someNodeTypeInSelection(nodeTypes, selectionMatchedAssetNodeIds.value)
),
...buildMissingModelGroupsForSelection(),
...buildMissingMediaGroupsForSelection(),
...buildMissingNodeGroups(),
...(filterByNode
? buildMissingModelGroupsFiltered()
: buildMissingModelGroups()),
...(filterByNode
? buildMissingMediaGroupsFiltered()
: buildMissingMediaGroups()),
...toSortedGroups(groupsMap)
]
})
/**
* Execution node ids referenced by any missing-asset candidate (models,
* media, missing node types).
*/
const assetNodeIdsWithError = computed<string[]>(() => {
const candidateIds = [
...(missingModelStore.missingModelCandidates ?? []),
...(missingMediaStore.missingMediaCandidates ?? [])
].map((candidate) => candidate.nodeId)
const missingNodeTypeIds = (
missingNodesStore.missingNodesError?.nodeTypes ?? []
).map((nodeType) =>
typeof nodeType === 'string' ? undefined : nodeType.nodeId
)
return [...candidateIds, ...missingNodeTypeIds]
.filter((nodeId) => nodeId != null)
.map(String)
})
/**
* Asset node ids that belong to the current selection. Drives row-level
* highlighting inside the missing-* cards.
*/
const selectionMatchedAssetNodeIds = computed<Set<string>>(() => {
if (!hasSelection.value) return new Set()
return new Set(
assetNodeIdsWithError.value.filter(isAssetCandidateInSelection)
)
})
const selectionMatchedGroupKeys = computed<Set<string>>(() => {
if (!hasSelection.value) return new Set()
return new Set(selectionScopedGroups.value.map((group) => group.groupKey))
})
const selectionMatchedCardIds = computed<Set<string>>(() => {
if (!hasSelection.value) return new Set()
return new Set(
selectionScopedGroups.value
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
.map((card) => card.id)
)
})
const selectionErrorCount = computed(() => {
if (!hasSelection.value) return 0
return selectionScopedGroups.value.reduce(
(sum, group) => sum + group.count,
0
)
})
/** Distinct nodes affected by any error (workflow-level summary). */
const errorNodeCount = computed(() => {
const executionNodeIds = allErrorGroups.value
.flatMap((group) => (group.type === 'execution' ? group.cards : []))
.map((card) => card.nodeId)
.filter((nodeId) => nodeId != null)
return new Set([...executionNodeIds, ...assetNodeIdsWithError.value]).size
})
const filteredGroups = computed<ErrorGroup[]>(() => {
const query = toValue(searchQuery).trim()
return searchErrorGroups(allErrorGroups.value, query)
return searchErrorGroups(tabErrorGroups.value, query)
})
return {
allErrorGroups,
tabErrorGroups,
filteredGroups,
collapseState,
isSingleNodeSelected,
errorNodeCache,
missingNodeCache,
missingPackGroups,
missingModelGroups,
missingMediaGroups,
swapNodeGroups,
hasSelection,
selectedNodeCount,
selectedNodeTitle,
selectionMatchedGroupKeys,
selectionMatchedCardIds,
selectionMatchedAssetNodeIds,
selectionErrorCount,
errorNodeCount
filteredMissingModelGroups,
filteredMissingMediaGroups,
swapNodeGroups
}
}

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

@@ -49,7 +49,6 @@
/>
<ResultVideo v-else-if="activeItem.isVideo" :result="activeItem" />
<ResultAudio v-else-if="activeItem.isAudio" :result="activeItem" />
<ResultText v-else-if="activeItem.isText" :result="activeItem" />
</template>
</div>
@@ -76,7 +75,6 @@ import Button from '@/components/ui/button/Button.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultText from './ResultText.vue'
import ResultVideo from './ResultVideo.vue'
const emit = defineEmits<{

View File

@@ -1,21 +0,0 @@
<template>
<article
class="m-auto max-h-[80vh] w-[min(90vw,42rem)] scroll-shadows-secondary-background overflow-y-auto rounded-lg bg-secondary-background p-4 whitespace-pre-wrap"
>
<span v-if="hasError" class="text-muted-foreground">
{{ $t('g.textFailedToLoad') }}
</span>
<template v-else>{{ textContent }}</template>
</article>
</template>
<script setup lang="ts">
import { useTextFileContent } from '@/composables/useTextFileContent'
import type { ResultItemImpl } from '@/stores/queueStore'
const { result } = defineProps<{
result: ResultItemImpl
}>()
const { textContent, hasError } = useTextFileContent(() => result)
</script>

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] text-sm text-amber-400" />
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
<span v-else class="text-base font-semibold text-base-foreground">{{
formattedBalance

View File

@@ -8,10 +8,7 @@ import {
PopoverTrigger
} from 'reka-ui'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
import { cn } from '@comfyorg/tailwind-utils'
defineOptions({
@@ -29,13 +26,10 @@ const {
to?: string | HTMLElement
showArrow?: boolean
}>()
const open = ref(false)
const contentStyle = useModalLiftedZIndex(open)
</script>
<template>
<PopoverRoot v-slot="{ close }" v-model:open="open">
<PopoverRoot v-slot="{ close }">
<PopoverTrigger as-child>
<slot name="button">
<Button size="icon">
@@ -49,7 +43,6 @@ const contentStyle = useModalLiftedZIndex(open)
:side-offset="5"
:collision-padding="10"
v-bind="$attrs"
:style="contentStyle"
class="data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
>
<slot :close>

View File

@@ -0,0 +1,25 @@
<template>
<CheckboxRoot
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',
className
)
"
>
<CheckboxIndicator class="flex items-center justify-center">
<i class="icon-[lucide--check] size-3" />
</CheckboxIndicator>
</CheckboxRoot>
</template>
<script setup lang="ts">
import { CheckboxIndicator, CheckboxRoot } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
const checked = defineModel<boolean>({ default: false })
</script>

View File

@@ -7,7 +7,6 @@ import {
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
import type { HSVA } from '@/utils/colorUtil'
import { hexToHsva, hsbToRgb, hsvaToHex, rgbToHex } from '@/utils/colorUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -61,7 +60,6 @@ const previewColor = computed(() => {
const displayHex = computed(() => rgbToHex(baseRgb.value).toLowerCase())
const isOpen = ref(false)
const contentStyle = useModalLiftedZIndex(isOpen)
</script>
<template>
@@ -118,7 +116,6 @@ const contentStyle = useModalLiftedZIndex(isOpen)
:side-offset="7"
:collision-padding="10"
class="z-1700"
:style="contentStyle"
>
<ColorPickerPanel
v-model:hsva="hsva"

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { DialogOverlayProps } from 'reka-ui'
import { DialogOverlay, Presence, injectDialogRootContext } from 'reka-ui'
import { DialogOverlay } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
@@ -8,27 +8,16 @@ import { cn } from '@comfyorg/tailwind-utils'
const { class: customClass = '', ...delegated } = defineProps<
DialogOverlayProps & { class?: HTMLAttributes['class'] }
>()
// Reka renders DialogOverlay only for modal dialogs; non-modal dialogs still
// need the scrim, so render a plain backdrop for them.
const rootContext = injectDialogRootContext()
const overlayClass =
'fixed inset-0 z-1700 bg-black/70 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0'
</script>
<template>
<DialogOverlay
v-if="rootContext.modal.value"
v-bind="delegated"
data-testid="dialog-overlay"
:class="cn(overlayClass, customClass)"
:class="
cn(
'fixed inset-0 z-1700 bg-black/70 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
customClass
)
"
/>
<Presence v-else :present="delegated.forceMount || rootContext.open.value">
<div
:data-state="rootContext.open.value ? 'open' : 'closed'"
data-testid="dialog-overlay"
:class="cn(overlayClass, customClass)"
/>
</Presence>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { HoverCardRoot, useForwardPropsEmits } from 'reka-ui'
import type { HoverCardRootEmits, HoverCardRootProps } from 'reka-ui'
import { provide, ref } from 'vue'
import { hoverCardOpenKey } from './hoverCardContext'
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
const props = defineProps<HoverCardRootProps>()
const emits = defineEmits<HoverCardRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
const isOpen = ref(false)
provide(hoverCardOpenKey, isOpen)
</script>
<template>
<HoverCardRoot v-bind="forwarded" v-model:open="isOpen">
<slot />
</HoverCardRoot>
</template>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import { HoverCardContent, HoverCardPortal, useForwardProps } from 'reka-ui'
import type { HoverCardContentProps } from 'reka-ui'
import { computed, inject } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
import { hoverCardOpenKey } from './hoverCardContext'
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
const MODAL_BASE_Z_INDEX = 1700
const {
class: className,
side = 'bottom',
sideOffset = 8,
...rest
} = defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>()
const forwarded = useForwardProps(computed(() => rest))
// Body-portaled content sits at a static z-1700 unless a dialog that joined
// @primeuix's 'modal' counter is open above it; then lift past that dialog.
const open = inject(hoverCardOpenKey, undefined)
const contentStyle = computed(() => {
if (!open?.value) return undefined
const topZIndex = ZIndex.getCurrent('modal')
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
})
</script>
<template>
<HoverCardPortal>
<HoverCardContent
v-bind="forwarded"
:side
:side-offset
:style="contentStyle"
:class="
cn(
'z-1700 rounded-lg border border-border-subtle bg-secondary-background p-2.5 shadow-md outline-none',
className
)
"
>
<slot />
</HoverCardContent>
</HoverCardPortal>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import { HoverCardTrigger } from 'reka-ui'
import type { HoverCardTriggerProps } from 'reka-ui'
const props = defineProps<HoverCardTriggerProps>()
</script>
<template>
<HoverCardTrigger v-bind="props">
<slot />
</HoverCardTrigger>
</template>

View File

@@ -0,0 +1,7 @@
import type { InjectionKey, Ref } from 'vue'
// Shares the root open-state with the content so it can lift its z-index above
// a dialog that joined @primeuix's incrementing 'modal' counter (otherwise the
// body-portaled content renders behind the settings dialog).
export const hoverCardOpenKey: InjectionKey<Ref<boolean>> =
Symbol('hoverCardOpen')

View File

@@ -0,0 +1,72 @@
<template>
<PaginationRoot
:page="page"
:total="total"
:items-per-page="itemsPerPage"
:sibling-count="1"
show-edges
@update:page="(p: number) => emit('update:page', p)"
>
<div class="flex items-center gap-1">
<PaginationPrev as-child>
<Button variant="muted-textonly" size="md" class="text-sm">
<i class="icon-[lucide--chevron-left] size-4" />
{{ $t('g.previous') }}
</Button>
</PaginationPrev>
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<template v-for="(item, index) in items" :key="index">
<PaginationListItem
v-if="item.type === 'page'"
:value="item.value"
as-child
>
<Button
:variant="item.value === page ? 'secondary' : 'muted-textonly'"
size="icon"
>
{{ item.value }}
</Button>
</PaginationListItem>
<PaginationEllipsis v-else :index="index" :class="ellipsisClass">
</PaginationEllipsis>
</template>
</PaginationList>
<PaginationNext as-child>
<Button variant="muted-textonly" size="md" class="text-sm">
{{ $t('g.next') }}
<i class="icon-[lucide--chevron-right] size-4" />
</Button>
</PaginationNext>
</div>
</PaginationRoot>
</template>
<script setup lang="ts">
import {
PaginationEllipsis,
PaginationList,
PaginationListItem,
PaginationNext,
PaginationPrev,
PaginationRoot
} from 'reka-ui'
import Button from '@/components/ui/button/Button.vue'
const {
page = 1,
total,
itemsPerPage = 10
} = defineProps<{
page?: number
total: number
itemsPerPage?: number
}>()
const emit = defineEmits<{ 'update:page': [page: number] }>()
const ellipsisClass =
'inline-flex size-8 items-center justify-center text-sm text-muted-foreground'
</script>

View File

@@ -35,7 +35,7 @@ export const searchInputSizeConfig = {
icon: 'size-4',
iconPos: 'left-2.5',
inputPl: 'pl-8',
inputText: 'text-xs',
inputText: 'text-sm',
clearPos: 'left-2.5'
},
xl: {

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

@@ -0,0 +1,16 @@
<script setup lang="ts">
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
const props = defineProps<TabsRootProps>()
const emits = defineEmits<TabsRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<TabsRoot v-bind="forwarded">
<slot />
</TabsRoot>
</template>

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