Compare commits

..

1 Commits

Author SHA1 Message Date
Terry Jia
ad9f0fa9ca feat: accept bboxes input and add grid snapping to Create Bounding Boxes 2026-07-06 22:53:44 -04:00
129 changed files with 1485 additions and 8103 deletions

View File

@@ -1,197 +0,0 @@
---
name: Backport Auto-Merge
# Completes the merge of backport PRs once they are approved and their required
# checks pass.
#
# Background: pr-backport.yaml opens each backport PR (labelled `backport`) and
# calls `gh pr merge --auto`, which relies on the repo-level "Allow auto-merge"
# setting. That setting is off, so `--auto` is a silent no-op and backport PRs
# sit unmerged until a human clicks merge. This workflow performs the merge
# directly (a plain `gh pr merge --squash`, which does not depend on that
# setting) once GitHub itself reports the PR as ready to merge.
#
# Safety: branch protection on core/** and cloud/** is the hard gate — it
# unconditionally requires an approval + the required status checks and cannot
# be bypassed, and GitHub's merge API re-enforces it at merge time. This
# workflow can only ever complete a merge that already satisfies those rules;
# the eligibility check below only avoids pointless merge attempts.
#
# The merge uses PR_GH_TOKEN (not the default GITHUB_TOKEN) on purpose: a merge
# performed by the default token does not emit events that trigger other
# workflows, which would silently starve cloud-backport-tag.yaml (it runs on the
# backport PR's `pull_request: closed` event to create the release tag).
on:
# Fires when someone approves — if the required checks are already green, the
# PR merges immediately.
pull_request_review:
types: [submitted]
# Primary catch for the "approved first, checks went green later" case, plus a
# general backstop. A `check_suite`/`workflow_run` trigger would react faster to
# checks completing, but GitHub suppresses `check_suite` events for its own
# Actions suites (so it wouldn't fire for this repo's CI), and `workflow_run` is
# a secrets-bearing "dangerous" trigger we don't want on a public repo for a
# non-latency-critical task. Backports wait hours today, so a short sweep is a
# large improvement and needs neither.
schedule:
- cron: '*/15 * * * *'
# Only constrains the default github.token (used for read-only PR lookups below).
# It does NOT constrain PR_GH_TOKEN, whose authority is fixed by its own scopes.
permissions:
contents: read # read-only; required for gh api / gh pr list to resolve candidates
pull-requests: read # read-only; required for gh pr view eligibility checks
# Serialize runs that act on the same PR (review events keyed by PR number; all
# scheduled sweeps share one key). Cross-key overlaps are still possible but
# harmless: the merge loop treats an already-merged PR as success (idempotent).
concurrency:
group: backport-auto-merge-${{ github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
jobs:
merge:
name: Merge eligible backport PRs
# Skip review events that can't possibly make a PR mergeable — non-approval
# reviews, or reviews on non-backport PRs (most reviews in the repo) — before
# spending any API call. Schedule sweeps always proceed. The per-PR
# eligibility checks in the job still re-verify the label and decision from
# live state.
if: github.event_name != 'pull_request_review' || (github.event.review.state == 'approved' && contains(github.event.pull_request.labels.*.name, 'backport'))
runs-on: ubuntu-latest
permissions:
contents: read # read-only PR/commit lookups via the default token
pull-requests: read # read-only PR metadata via the default token
steps:
- name: Collect candidate backport PRs
id: candidates
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
PR_FROM_REVIEW: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
numbers=""
case "$EVENT_NAME" in
pull_request_review)
numbers="$PR_FROM_REVIEW"
;;
schedule)
# Sweep every open backport PR.
numbers=$(gh pr list --repo "$GH_REPO" --state open --label backport \
--limit 100 --json number --jq '.[].number')
;;
esac
# De-duplicate and emit space-separated, digit-only tokens.
numbers=$(echo "$numbers" | tr ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true)
echo "numbers=${numbers}" >> "$GITHUB_OUTPUT"
echo "Candidate PRs: '${numbers:-<none>}'"
- name: Merge eligible backport PRs
if: steps.candidates.outputs.numbers != ''
env:
GH_REPO: ${{ github.repository }}
# Read with the default token; merge with PR_GH_TOKEN so the merge emits
# the events that downstream workflows (cloud-backport-tag.yaml) rely on.
READ_TOKEN: ${{ github.token }}
MERGE_TOKEN: ${{ secrets.PR_GH_TOKEN }}
CANDIDATES: ${{ steps.candidates.outputs.numbers }}
run: |
set -euo pipefail
is_merged() {
[ "$(GH_TOKEN="$READ_TOKEN" gh pr view "$1" --repo "$GH_REPO" --json merged --jq '.merged' 2>/dev/null || echo false)" = "true" ]
}
for pr in $CANDIDATES; do
echo "::group::PR #${pr}"
info=$(GH_TOKEN="$READ_TOKEN" gh pr view "$pr" --repo "$GH_REPO" \
--json number,state,isDraft,labels,baseRefName,reviewDecision,mergeStateStatus 2>/dev/null || echo '')
if [ -z "$info" ]; then
echo "Could not read PR #${pr} — skipping."; echo "::endgroup::"; continue
fi
state=$(echo "$info" | jq -r '.state')
is_draft=$(echo "$info" | jq -r '.isDraft')
is_backport=$(echo "$info" | jq -r '[.labels[].name] | any(. == "backport")')
base=$(echo "$info" | jq -r '.baseRefName')
review=$(echo "$info" | jq -r '.reviewDecision')
merge_state=$(echo "$info" | jq -r '.mergeStateStatus')
# Only ever act on open, non-draft, backport-labelled PRs targeting a
# protected release branch.
if [ "$state" != "OPEN" ] || [ "$is_draft" != "false" ] || [ "$is_backport" != "true" ]; then
echo "Not an actionable backport PR (state=$state draft=$is_draft backport=$is_backport) — skipping."
echo "::endgroup::"; continue
fi
case "$base" in
cloud/*|core/*) : ;;
*) echo "Base '$base' is not a release branch — skipping."; echo "::endgroup::"; continue ;;
esac
# Ready = approved AND GitHub says it's mergeable with required checks green.
# CLEAN = approved, all required checks green, mergeable, no conflict.
# UNSTABLE = same, but a NON-required check is pending/failing — GitHub
# still allows the merge, so we do too (matches what a human
# clicking "Squash and merge" can do; required checks are the
# only merge gate per the ruleset). Requiring CLEAN alone would
# stick forever behind flaky/slow non-required checks.
# Any other state (BLOCKED/DIRTY/BEHIND/UNKNOWN/...) => not ready; re-checked
# by a later event or the next sweep.
if [ "$review" != "APPROVED" ] || { [ "$merge_state" != "CLEAN" ] && [ "$merge_state" != "UNSTABLE" ]; }; then
echo "Not yet ready (reviewDecision=$review mergeStateStatus=$merge_state) — will re-check later."
echo "::endgroup::"; continue
fi
echo "PR #${pr} is ready — attempting squash merge."
attempt=0
max=3
merged=false
while [ "$attempt" -lt "$max" ]; do
attempt=$((attempt + 1))
# A concurrent run (or a human) may have merged it already.
if is_merged "$pr"; then merged=true; break; fi
if out=$(GH_TOKEN="$MERGE_TOKEN" gh pr merge "$pr" --repo "$GH_REPO" --squash 2>&1); then
merged=true; break
fi
echo "Merge attempt ${attempt}/${max} failed: ${out}"
# No sleep after the final attempt.
[ "$attempt" -lt "$max" ] && sleep $((attempt * 15))
done
# Final reconciliation: a failed merge command may just mean a concurrent
# run won the race — don't post a false failure if the PR is in fact merged.
if [ "$merged" != "true" ] && is_merged "$pr"; then merged=true; fi
if [ "$merged" = "true" ]; then
echo "PR #${pr} merged."
else
echo "::warning::PR #${pr} looked ready but did not merge after ${max} attempts."
# Avoid spamming a persistently-stuck PR: only re-warn if the last
# warning (identified by its marker) is more than an hour old.
marker='<!-- backport-auto-merge:merge-failed -->'
# `gh api --paginate` emits one JSON array per page; `--jq` would run
# per page (missing the true latest across pages), so slurp all pages
# into one array first and filter with a separate jq pass.
last_warned=$(GH_TOKEN="$READ_TOKEN" gh api "repos/${GH_REPO}/issues/${pr}/comments" --paginate 2>/dev/null \
| jq -s "[.[][] | select(.body | contains(\"${marker}\"))] | sort_by(.created_at) | last | .created_at // empty") || last_warned=''
stale=true
if [ -n "$last_warned" ]; then
last_epoch=$(date -d "$last_warned" +%s 2>/dev/null || echo 0)
now_epoch=$(date -u +%s)
[ $((now_epoch - last_epoch)) -lt 3600 ] && stale=false
fi
if [ "$stale" = "true" ]; then
body=$(printf '%s\n\n%s' \
"This backport PR is approved and its required checks are green, but automatic merge failed after ${max} attempts. Please merge manually or investigate (possible branch-protection mismatch)." \
"$marker")
GH_TOKEN="$MERGE_TOKEN" gh pr comment "$pr" --repo "$GH_REPO" --body "$body" || true
else
echo "Already warned within the last hour — skipping duplicate comment."
fi
fi
echo "::endgroup::"
done

View File

@@ -6,7 +6,6 @@ on:
pull_request_target:
types: [opened, synchronize, closed]
merge_group:
types: [checks_requested]
permissions:
actions: write
@@ -18,45 +17,13 @@ jobs:
cla-assistant:
runs-on: ubuntu-latest
steps:
- name: CLA already verified before merge queue
if: github.event_name == 'merge_group'
run: echo "CLA is checked on the pull request before it enters merge queue."
# The CLA action normally requires every commit author in a PR to sign.
# We only want the PR author to sign, so we allowlist all other committers
# by computing them from the PR's commits and excluding the PR author.
- name: Build author-only allowlist
id: allowlist
if: >
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
))
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
run: |
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
if [ -n "$others" ]; then
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
else
echo "allowlist=${BASE_ALLOWLIST}" >> "$GITHUB_OUTPUT"
fi
- name: CLA Assistant
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
if: >
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' && github.event.issue.pull_request && (
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
))
github.event.comment.body == 'recheck' ||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -72,10 +39,9 @@ jobs:
path-to-signatures: signatures/cla.json
branch: main
# Only the PR author must sign: bots plus every non-author committer
# are allowlisted via the "Build author-only allowlist" step above.
# Allowlist bots so they don't need to sign (optional, comma-separated).
# *[bot] is a catch-all for any GitHub App bot account.
allowlist: ${{ steps.allowlist.outputs.allowlist }}
allowlist: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,Glary Bot,Glary-Bot,*[bot]
# Custom PR comment messages
custom-notsigned-prcomment: |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 938 B

After

Width:  |  Height:  |  Size: 3.2 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 56 KiB

View File

@@ -1,10 +0,0 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1483_15836)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.373 184.704V136.491C196.373 132.437 197.909 129.387 201.451 127.36L298.368 71.552C311.573 63.936 327.296 60.3947 343.531 60.3947C404.416 60.3947 442.987 107.584 442.987 157.803C442.987 161.365 442.987 165.419 442.475 169.472L341.995 110.613C339.266 108.876 336.099 107.954 332.864 107.954C329.629 107.954 326.462 108.876 323.733 110.613L196.373 184.704ZM422.699 372.437V257.28C422.699 250.176 419.648 245.12 413.547 241.557L286.187 167.467L327.787 143.616C329.294 142.624 331.059 142.095 332.864 142.095C334.669 142.095 336.434 142.624 337.941 143.616L434.859 199.445C462.784 215.659 481.557 250.176 481.557 283.669C481.557 322.24 458.731 357.76 422.677 372.48L422.699 372.437ZM166.443 270.997L124.843 246.635C121.28 244.608 119.744 241.557 119.744 237.504V125.845C119.744 71.552 161.344 30.4427 217.685 30.4427C239.019 30.4427 258.795 37.5467 275.541 50.24L175.573 108.096C169.493 111.637 166.443 116.715 166.443 123.819V270.976V270.997ZM256 322.731L196.373 289.237V218.197L256 184.704L315.627 218.197V289.237L256 322.731ZM294.315 476.971C272.981 476.971 253.205 469.888 236.459 457.195L336.427 399.339C342.507 395.797 345.557 390.72 345.557 383.616V236.459L387.669 260.821C391.232 262.848 392.747 265.899 392.747 269.952V381.589C392.747 435.883 350.635 476.971 294.315 476.971ZM174.059 363.84L77.12 308.011C49.216 291.776 30.4427 257.28 30.4427 223.787C30.3769 204.756 35.9917 186.138 46.5684 170.317C57.1451 154.495 72.2025 142.19 89.8133 134.976V250.667C89.8133 257.771 92.864 262.848 98.944 266.411L225.813 339.989L184.213 363.84C182.707 364.835 180.941 365.365 179.136 365.365C177.331 365.365 175.565 364.835 174.059 363.84ZM168.469 447.04C111.125 447.04 69.0133 403.925 69.0133 350.635C69.0133 346.581 69.5253 342.528 70.016 338.475L169.984 396.288C176.085 399.851 182.165 399.851 188.245 396.288L315.605 322.731V370.944C315.605 374.997 314.112 378.048 310.549 380.075L213.632 435.883C200.427 443.499 184.704 447.04 168.469 447.04ZM294.315 507.413C323.553 507.416 351.895 497.319 374.547 478.831C397.198 460.343 412.768 434.598 418.624 405.952C475.456 391.232 512 337.92 512 283.648C512 248.128 496.789 213.632 469.376 188.757C471.915 178.091 473.429 167.445 473.429 156.8C473.429 84.2453 414.571 29.9307 346.581 29.9307C332.885 29.9307 319.701 31.9573 306.475 36.544C282.795 13.2354 250.933 0.118797 217.707 1.37049e-07C188.465 -0.00135846 160.121 10.0985 137.469 28.5908C114.817 47.0831 99.2486 72.8325 93.3973 101.483C36.544 116.203 0 169.493 0 223.787C0 259.328 15.2107 293.824 42.624 318.677C40.0853 329.344 38.5707 340.011 38.5707 350.656C38.5707 423.211 97.4293 477.504 165.419 477.504C179.115 477.504 192.299 475.477 205.525 470.912C229.208 494.23 261.08 507.347 294.315 507.456V507.413Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1483_15836">
<rect width="512" height="512" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -1,28 +0,0 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import { ChevronRight } from '@lucide/vue'
import type { HTMLAttributes } from 'vue'
const { hover = 'self', class: className } = defineProps<{
hover?: 'self' | 'group'
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="
cn(
'flex size-10 items-center justify-center rounded-2xl bg-white/20 text-white backdrop-blur-sm transition-colors',
hover === 'group'
? 'group-hover:bg-primary-comfy-yellow group-hover:text-primary-comfy-ink'
: 'hover:bg-primary-comfy-yellow hover:text-primary-comfy-ink',
className
)
"
aria-hidden="true"
>
<ChevronRight class="size-5" :stroke-width="2" />
</div>
</template>

View File

@@ -1,8 +1,6 @@
<script setup lang="ts">
import { cn } from '@comfyorg/tailwind-utils'
import Button from '../ui/button/Button.vue'
const { title, description, cta, href, bg } = defineProps<{
title: string
description: string
@@ -30,9 +28,11 @@ 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">
<span
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-4 inline-block rounded-xl px-4 py-2 text-xs font-bold tracking-wide"
>
{{ cta }}
</Button>
</span>
</div>
</a>
</template>

View File

@@ -1,9 +1,7 @@
<script setup lang="ts">
import type { Locale } from '../../../i18n/translations'
import { externalLinks } from '../../../config/routes'
import { t } from '../../../i18n/translations'
import CardArrow from '../../common/CardArrow.vue'
import GlassCard from '../../common/GlassCard.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -29,7 +27,7 @@ const cards = [
<template>
<section class="max-w-9xl mx-auto px-4 pt-24 lg:px-20 lg:pt-40">
<h2
class="text-3.5xl/tight mx-auto max-w-3xl text-center font-light text-primary-comfy-canvas lg:text-5xl/tight"
class="text-primary-comfy-canvas text-3.5xl/tight mx-auto max-w-3xl text-center font-light lg:text-5xl/tight"
>
{{ headingParts[0]
}}<span class="text-white">{{
@@ -39,11 +37,10 @@ const cards = [
</h2>
<GlassCard class="mt-12 grid grid-cols-1 gap-6 lg:mt-20 lg:grid-cols-2">
<a
<div
v-for="card in cards"
:key="card.labelKey"
:href="externalLinks.cloud"
class="group rounded-4.5xl block overflow-hidden bg-primary-comfy-ink"
class="bg-primary-comfy-ink rounded-4.5xl overflow-hidden"
>
<img
:src="card.image"
@@ -54,27 +51,23 @@ const cards = [
/>
<div class="mt-8 p-6">
<div class="flex items-center justify-between gap-4">
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
>
{{ t(card.labelKey, locale) }}
</p>
<CardArrow hover="group" class="shrink-0" />
</div>
<p
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
>
{{ t(card.labelKey, locale) }}
</p>
<h3
class="mt-8 text-3xl/tight font-light whitespace-pre-line text-primary-comfy-canvas"
class="text-primary-comfy-canvas mt-8 text-3xl/tight font-light whitespace-pre-line"
>
{{ t(card.titleKey, locale) }}
</h3>
<p class="mt-8 text-base/normal text-primary-comfy-canvas">
<p class="text-primary-comfy-canvas mt-8 text-base/normal">
{{ t(card.descriptionKey, locale) }}
</p>
</div>
</a>
</div>
</GlassCard>
</section>
</template>

View File

@@ -17,18 +17,18 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
>
<div class="max-w-2xl">
<h2
class="text-2xl/tight font-medium text-primary-comfy-ink lg:text-3xl/tight"
class="text-primary-comfy-ink text-2xl/tight font-medium lg:text-3xl/tight"
>
{{ t('cloud.pricing.title', locale) }}
</h2>
<p class="mt-4 text-base text-primary-comfy-ink">
<p class="text-primary-comfy-ink mt-4 text-base">
{{ t('cloud.pricing.description', locale) }}
</p>
<p
v-if="SHOW_FREE_TIER"
class="mt-4 text-base font-bold text-primary-comfy-ink"
class="text-primary-comfy-ink mt-4 text-base font-bold"
>
{{ t('cloud.pricing.tagline', locale) }}
</p>
@@ -36,7 +36,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
<a
:href="getRoutes(locale).cloudPricing"
class="text-primary-comfy-yellow shrink-0 rounded-2xl bg-primary-comfy-ink px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
class="bg-primary-comfy-ink text-primary-comfy-yellow shrink-0 rounded-2xl px-6 py-3 text-center text-sm font-semibold transition-opacity hover:opacity-90"
>
{{ t('cloud.pricing.cta', locale) }}
</a>

View File

@@ -6,7 +6,6 @@ import type { Locale } from '../../../i18n/translations'
import { externalLinks } from '../../../config/routes'
import { t } from '../../../i18n/translations'
import BrandButton from '../../common/BrandButton.vue'
import CardArrow from '../../common/CardArrow.vue'
type ModelCard = {
titleKey:
@@ -15,10 +14,11 @@ type ModelCard = {
| 'cloud.aiModels.card.seedance20'
| 'cloud.aiModels.card.qwenImageEdit'
| 'cloud.aiModels.card.wan22TextToVideo'
| 'cloud.aiModels.card.gptImage2'
imageSrc: string
badgeIcon: string
badgeClass: string
layoutClass: string
objectPosition?: string
}
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
@@ -32,45 +32,48 @@ const modelCards: ModelCard[] = [
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/seedance-20.webm',
badgeIcon: '/icons/ai-models/bytedance.svg',
badgeClass: `${badgeBase} rounded-2xl`
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]'
},
{
titleKey: 'cloud.aiModels.card.nanoBananaPro',
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/nano-banana-pro.webp',
badgeIcon: '/icons/ai-models/gemini.svg',
badgeClass: `${badgeBase} rounded-2xl`
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-6 lg:aspect-[16/7]',
objectPosition: 'center 20%'
},
{
titleKey: 'cloud.aiModels.card.grokImagine',
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/grok-video.webm',
badgeIcon: '/icons/ai-models/grok.svg',
badgeClass: `${badgeBase} rounded-2xl`
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
},
{
titleKey: 'cloud.aiModels.card.qwenImageEdit',
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/qwen-image-edit.webp',
badgeIcon: '/icons/ai-models/qwen.svg',
badgeClass: `${badgeBase} rounded-2xl`
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
},
{
titleKey: 'cloud.aiModels.card.wan22TextToVideo',
imageSrc: 'https://media.comfy.org/website/cloud/ai-models/wan-22.webm',
badgeIcon: '/icons/ai-models/wan.svg',
badgeClass: `${badgeBase} rounded-2xl`
},
{
titleKey: 'cloud.aiModels.card.gptImage2',
imageSrc:
'https://media.comfy.org/website/cloud/ai-models/gpt-image-2.webm',
badgeIcon: '/icons/ai-models/openai.svg',
badgeClass: `${badgeBase} rounded-2xl`
badgeClass: `${badgeBase} rounded-2xl`,
layoutClass: 'lg:col-span-4 lg:aspect-[4/3]'
}
]
const cardClass =
'group relative h-72 cursor-pointer overflow-hidden rounded-3xl bg-black/40 lg:col-span-4 lg:aspect-square lg:h-auto'
function getCardClass(layoutClass: string): string {
return cn(
layoutClass,
'group relative h-72 cursor-pointer overflow-hidden rounded-4xl bg-black/40 lg:h-auto'
)
}
</script>
<template>
@@ -97,18 +100,23 @@ const cardClass =
</p>
<div class="mt-16 w-full lg:mt-24">
<div class="rounded-4xl bg-white/8 p-2 lg:p-1.5">
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
<a
v-for="card in modelCards"
:key="card.titleKey"
:href="externalLinks.workflows"
:class="cardClass"
:class="getCardClass(card.layoutClass)"
>
<video
v-if="card.imageSrc.endsWith('.webm')"
:src="card.imageSrc"
:aria-label="t(card.titleKey, locale)"
:style="
card.objectPosition
? { objectPosition: card.objectPosition }
: undefined
"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
autoplay
loop
@@ -126,6 +134,11 @@ const cardClass =
v-else
:src="card.imageSrc"
:alt="t(card.titleKey, locale)"
:style="
card.objectPosition
? { objectPosition: card.objectPosition }
: undefined
"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
decoding="async"
@@ -155,14 +168,10 @@ const cardClass =
</div>
<p
class="text-primary-warm-white absolute right-20 bottom-6 left-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
class="text-primary-warm-white absolute inset-x-6 bottom-6 text-2xl/tight font-light whitespace-pre-line drop-shadow-[0_2px_8px_rgba(0,0,0,0.9)] lg:top-6 lg:right-auto lg:bottom-auto lg:text-3xl"
>
{{ t(card.titleKey, locale) }}
</p>
<CardArrow
class="absolute right-5 bottom-5 lg:right-6 lg:bottom-6"
/>
</a>
</div>
</div>

View File

@@ -932,9 +932,9 @@ const translations = {
'zh-CN': '所有模型。\n商业许可保证。'
},
'cloud.reason.2.description': {
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D, GPT Image 2 and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
en: 'Run open-source models like Wan 2.2, Flux, LTX and Qwen alongside partner models like Nano Banana, Seedance, Seedream, Grok, Kling, Hunyuan 3D and more. Every model on Comfy Cloud is cleared for commercial use. No license ambiguity. All through one credit balance.',
'zh-CN':
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D、GPT Image 2 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
'运行 Wan 2.2、Flux、LTX 和 Qwen 等开源模型,以及 Nano Banana、Seedance、Seedream、Grok、Kling、Hunyuan 3D 等合作伙伴模型。Comfy Cloud 上的每个模型都已获得商业使用许可。无许可证歧义。通过统一的积分余额使用。'
},
'cloud.reason.2.badge.onlyOn': {
en: 'ONLY ON',
@@ -996,10 +996,6 @@ const translations = {
en: 'Wan 2.2',
'zh-CN': 'Wan 2.2'
},
'cloud.aiModels.card.gptImage2': {
en: 'GPT Image 2',
'zh-CN': 'GPT Image 2'
},
'cloud.aiModels.ctaDesktop': {
en: 'EXPLORE WORKFLOWS WITH THE LATEST MODELS',
'zh-CN': '探索最新模型工作流'

View File

@@ -248,7 +248,7 @@
@utility ppformula-text-center {
display: inline-block;
position: relative;
top: 0.1em;
top: 0.19em;
}
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked

View File

@@ -4,37 +4,72 @@
data-testid="bounding-boxes"
@pointerdown.stop
>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
<div class="flex flex-col">
<div
class="flex h-9 items-center gap-1 rounded-t-sm border border-b-0 border-component-node-border bg-component-node-widget-background px-2"
>
<Button
v-tooltip.bottom="{ value: $t('boundingBoxes.grid'), showDelay: 300 }"
variant="textonly"
size="unset"
:aria-pressed="grid"
:class="
cn(
actionBtnClass,
grid && 'bg-component-node-widget-background-selected'
)
"
@click="grid = !grid"
>
<i class="icon-[lucide--grid-3x3] size-4" />
<span>{{ $t('boundingBoxes.grid') }}</span>
</Button>
<Button
v-tooltip.bottom="{
value: $t('boundingBoxes.clearAll'),
showDelay: 300
}"
variant="textonly"
size="unset"
:class="cn(actionBtnClass, 'ml-auto')"
@click="clearAll"
>
<i class="icon-[lucide--undo-2] size-4" />
<span>{{ $t('boundingBoxes.clearAll') }}</span>
</Button>
</div>
<div
ref="canvasContainer"
class="relative w-full shrink-0 overflow-hidden rounded-b-sm border border-t-0 border-component-node-border bg-base-background"
:style="canvasStyle"
>
<canvas
ref="canvasEl"
tabindex="0"
class="absolute inset-0 size-full rounded-sm outline-none"
:style="{ cursor: canvasCursor }"
@pointerdown="onPointerDown"
@pointermove="onCanvasPointerMove"
@pointerup="onDocPointerUp"
@pointercancel="onDocPointerUp"
@pointerleave="onPointerLeave"
@lostpointercapture="onDocPointerUp"
@dblclick="onDoubleClick"
@keydown="onCanvasKeyDown"
@focus="focused = true"
@blur="focused = false"
/>
<textarea
v-if="inlineEditor"
ref="inlineEditorEl"
v-model="inlineEditor.value"
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
:style="inlineEditor.style"
data-capture-wheel="true"
@keydown.stop="onInlineKeyDown"
@blur="commitInlineEditor"
/>
</div>
</div>
<div
@@ -122,16 +157,6 @@
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
{{ $t('boundingBoxes.clickRegionToEdit') }}
</div>
<Button
variant="secondary"
size="md"
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
@click="clearAll"
>
<i class="icon-[lucide--undo-2]" />
{{ $t('boundingBoxes.clearAll') }}
</Button>
</div>
</template>
@@ -147,6 +172,9 @@ import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
import type { NodeId } from '@/types/nodeId'
const actionBtnClass =
'flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-component-node-widget-background-hovered'
const { nodeId } = defineProps<{ nodeId: NodeId }>()
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
@@ -172,7 +200,8 @@ const {
commitInlineEditor,
setActiveType,
clearAll,
syncState
syncState,
grid
} = useBoundingBoxes(nodeId, {
canvasEl,
canvasContainer,

View File

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

View File

@@ -1,40 +0,0 @@
<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

@@ -318,22 +318,6 @@ 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

@@ -9,11 +9,10 @@ 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 (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.
// dismiss itself. These selectors cover the portaled roots 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"], [aria-haspopup="menu"], [aria-haspopup="dialog"], [aria-haspopup="listbox"]'
'[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"]'
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`

View File

@@ -32,7 +32,7 @@ describe('PaletteSwatchRow', () => {
it('appends a color when the add button is clicked', async () => {
const { emitted } = renderRow(['#ff0000'])
await userEvent.click(screen.getByRole('button'))
await userEvent.click(screen.getByRole('button', { name: '+' }))
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
})
@@ -44,18 +44,14 @@ describe('PaletteSwatchRow', () => {
it('hides the add button once the max is reached', () => {
renderRow(['#a', '#b'], 2)
expect(screen.queryByRole('button')).toBeNull()
expect(screen.queryByRole('button', { name: '+' })).toBeNull()
})
it('writes a picked color back through the hidden color input', async () => {
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
await fireEvent.click(container.querySelector('[data-index="1"]')!)
const input = container.querySelector(
'input[type="color"]'
) as HTMLInputElement
input.value = '#0000ff'
await fireEvent.input(input)
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
it('opens the color picker when a swatch is clicked', async () => {
const { container } = renderRow(['#ff0000'])
const swatch = container.querySelector('[data-index="0"]')!
await userEvent.click(swatch)
expect(swatch.getAttribute('data-state')).toBe('open')
})
it('starts a drag on pointer down without emitting', async () => {

View File

@@ -1,17 +1,24 @@
<template>
<div ref="container" class="flex flex-wrap items-center gap-1">
<div
<ColorPicker
v-for="(hex, i) in modelValue"
:key="`${i}-${hex}`"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@click="openPicker(i, $event)"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
:key="i"
:model-value="hex"
@update:model-value="(value) => updateAt(i, value)"
>
<template #trigger>
<button
type="button"
:data-index="i"
:data-hex="hex"
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border p-0"
:style="{ background: hex }"
:title="t('palette.swatchTitle')"
@contextmenu.prevent.stop="remove(i)"
@pointerdown="onPointerDown(i, $event)"
/>
</template>
</ColorPicker>
<button
v-if="modelValue.length < max"
type="button"
@@ -21,12 +28,6 @@
>
+
</button>
<input
ref="picker"
type="color"
class="pointer-events-none absolute size-0 opacity-0"
@input="onPickerInput"
/>
</div>
</template>
@@ -34,6 +35,7 @@
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
const { max = 5 } = defineProps<{ max?: number }>()
@@ -41,8 +43,9 @@ const modelValue = defineModel<string[]>({ required: true })
const { t } = useI18n()
const container = useTemplateRef<HTMLDivElement>('container')
const picker = useTemplateRef<HTMLInputElement>('picker')
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
usePaletteSwatchRow({ modelValue, container, picker })
const { updateAt, remove, addColor, onPointerDown } = usePaletteSwatchRow({
modelValue,
container
})
</script>

View File

@@ -1,25 +0,0 @@
<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

@@ -65,49 +65,51 @@ const isOpen = ref(false)
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<button
type="button"
:disabled="$props.disabled"
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'border-node-stroke',
$props.class
)
"
>
<div class="flex size-8 shrink-0 items-center justify-center">
<div class="relative size-4 overflow-hidden rounded-sm">
<div
class="absolute inset-0"
:style="{
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '4px 4px'
}"
/>
<div
class="absolute inset-0"
:style="{ backgroundColor: previewColor }"
/>
</div>
</div>
<div
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
<slot name="trigger">
<button
type="button"
:disabled="$props.disabled"
:class="
cn(
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'border-node-stroke',
$props.class
)
"
>
<template v-if="displayMode === 'hex'">
<span>{{ displayHex }}</span>
</template>
<template v-else>
<div class="flex gap-2">
<span>{{ baseRgb.r }}</span>
<span>{{ baseRgb.g }}</span>
<span>{{ baseRgb.b }}</span>
<div class="flex size-8 shrink-0 items-center justify-center">
<div class="relative size-4 overflow-hidden rounded-sm">
<div
class="absolute inset-0"
:style="{
backgroundImage:
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
backgroundSize: '4px 4px'
}"
/>
<div
class="absolute inset-0"
:style="{ backgroundColor: previewColor }"
/>
</div>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</div>
<div
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
>
<template v-if="displayMode === 'hex'">
<span>{{ displayHex }}</span>
</template>
<template v-else>
<div class="flex gap-2">
<span>{{ baseRgb.r }}</span>
<span>{{ baseRgb.g }}</span>
<span>{{ baseRgb.b }}</span>
</div>
</template>
<span>{{ hsva.a }}%</span>
</div>
</button>
</slot>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent

View File

@@ -1,22 +0,0 @@
<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

@@ -1,51 +0,0 @@
<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

@@ -1,12 +0,0 @@
<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

@@ -1,7 +0,0 @@
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

@@ -1,72 +0,0 @@
<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-sm',
inputText: 'text-xs',
clearPos: 'left-2.5'
},
xl: {

View File

@@ -1,30 +0,0 @@
<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

@@ -1,17 +0,0 @@
<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

@@ -1,13 +0,0 @@
<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

@@ -1,13 +0,0 @@
<template>
<td :class="cn('px-2 py-2.5 align-middle', 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

@@ -1,20 +0,0 @@
<template>
<th
:class="
cn(
'h-10 px-2 text-left align-middle text-sm font-normal 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

@@ -1,15 +0,0 @@
<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

@@ -1,20 +0,0 @@
<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

@@ -1,16 +0,0 @@
<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>

View File

@@ -1,20 +0,0 @@
<script setup lang="ts">
import { TabsList } from 'reka-ui'
import type { TabsListProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...rest } = defineProps<
TabsListProps & { class?: HTMLAttributes['class'] }
>()
</script>
<template>
<TabsList
v-bind="rest"
:class="cn('inline-flex items-center gap-4', className)"
>
<slot />
</TabsList>
</template>

View File

@@ -1,28 +0,0 @@
<script setup lang="ts">
import { TabsTrigger, useForwardProps } from 'reka-ui'
import type { TabsTriggerProps } from 'reka-ui'
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@comfyorg/tailwind-utils'
const { class: className, ...rest } = defineProps<
TabsTriggerProps & { class?: HTMLAttributes['class'] }
>()
const forwarded = useForwardProps(computed(() => rest))
</script>
<template>
<TabsTrigger
v-bind="forwarded"
:class="
cn(
'cursor-pointer appearance-none border-0 border-b-2 border-transparent bg-transparent px-0 pb-2 text-sm text-muted-foreground transition-colors outline-none data-[state=active]:border-base-foreground data-[state=active]:text-base-foreground',
className
)
"
>
<slot />
</TabsTrigger>
</template>

View File

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

View File

@@ -8,14 +8,32 @@ import { useBoundingBoxes } from './useBoundingBoxes'
import type { BoundingBox } from '@/types/boundingBoxes'
import { toNodeId } from '@/types/nodeId'
const { appState } = vi.hoisted(() => ({
appState: { node: null as unknown }
const { appState, outputState } = vi.hoisted(() => ({
appState: { node: null as unknown },
outputState: {
outputs: undefined as unknown,
nodeOutputs: null as { value: Record<string, unknown> } | null
}
}))
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById: () => appState.node } } }
}))
vi.mock('@/stores/nodeOutputStore', async () => {
const { ref } = await import('vue')
const nodeOutputs = ref<Record<string, unknown>>({})
outputState.nodeOutputs = nodeOutputs
return {
useNodeOutputStore: () => ({
nodeOutputs,
nodePreviewImages: ref({}),
getNodeImageUrls: () => undefined,
getNodeOutputs: () => outputState.outputs
})
}
})
const ctx = {
measureText: (s: string) => ({ width: s.length * 7 }),
setTransform: () => {},
@@ -27,6 +45,9 @@ const ctx = {
save: () => {},
restore: () => {},
beginPath: () => {},
moveTo: () => {},
arc: () => {},
fill: () => {},
rect: () => {},
clip: () => {},
font: '',
@@ -128,9 +149,23 @@ const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
...over
})
function makeConnectedNode() {
return {
widgets: [
{ name: 'width', value: 512 },
{ name: 'height', value: 512 }
],
findInputSlot: (name: string) => (name === 'bboxes' ? 1 : -1),
getInputNode: () => null,
isInputConnected: () => true
}
}
beforeEach(() => {
setActivePinia(createPinia())
appState.node = makeNode()
outputState.outputs = undefined
if (outputState.nodeOutputs) outputState.nodeOutputs.value = {}
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
void Promise.resolve().then(() => cb(0))
return 1
@@ -239,6 +274,77 @@ describe('useBoundingBoxes inline editor', () => {
})
})
describe('useBoundingBoxes incoming bboxes input', () => {
it('overrides the canvas when the bboxes input is connected', () => {
appState.node = makeConnectedNode()
outputState.outputs = {
input_bboxes: [box({ x: 0, y: 0, width: 100, height: 100 })]
}
const c = setup([])
expect(c.modelValue.value).toHaveLength(1)
expect(c.modelValue.value[0].width).toBe(100)
})
it('replaces existing drawn boxes with the incoming ones', () => {
appState.node = makeConnectedNode()
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
const c = setup([box({ x: 200, width: 300 }), box({ x: 400, width: 50 })])
expect(c.modelValue.value).toHaveLength(1)
expect(c.modelValue.value[0].width).toBe(100)
})
it('ignores incoming output when the input is not connected', () => {
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
const c = setup([])
expect(c.modelValue.value).toHaveLength(0)
})
it('applies incoming boxes when outputs stream in after mount', async () => {
appState.node = makeConnectedNode()
const c = setup([])
expect(c.modelValue.value).toHaveLength(0)
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
outputState.nodeOutputs!.value = { updated: true }
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.modelValue.value[0].width).toBe(100)
})
})
describe('useBoundingBoxes grid snapping', () => {
it('snaps a drawn box to the grid when grid is enabled (default)', async () => {
const c = setup()
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(60, 60))
c.onDocPointerUp(pe(60, 60))
await flush()
expect(c.modelValue.value).toHaveLength(1)
expect(c.modelValue.value[0].x).toBe(64)
expect(c.modelValue.value[0].width).toBe(256)
})
it('does not snap when grid is disabled', async () => {
const c = setup()
c.grid.value = false
c.onPointerDown(pe(10, 10))
c.onCanvasPointerMove(pe(55, 55))
c.onDocPointerUp(pe(55, 55))
await flush()
expect(c.modelValue.value[0].width).toBe(230)
})
it('keeps the anchored edge fixed when resizing a single edge', async () => {
const c = setup([box({ x: 51, y: 51, width: 256, height: 256 })])
c.onPointerDown(pe(60, 30))
c.onCanvasPointerMove(pe(80, 30))
c.onDocPointerUp(pe(80, 30))
await flush()
expect(c.modelValue.value[0].x).toBe(51)
})
})
describe('useBoundingBoxes hover cursor', () => {
it('switches to a pointer cursor over a tag', async () => {
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])

View File

@@ -15,6 +15,7 @@ import type {
Region
} from '@/composables/boundingBoxes/boundingBoxesUtil'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { NodeOutputWith } from '@/schemas/apiSchema'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { BoundingBox } from '@/types/boundingBoxes'
@@ -25,6 +26,10 @@ const HANDLE_PX = 8
const DIMENSION_STEP = 16
const BG_DIM = 0.75
const MAX_ELEMENT_COLORS = 5
const GRID_PX = 32
const MAX_GRID_CELLS = 64
const DOT_COLOR = 'rgba(255,255,255,0.18)'
const DOT_RADIUS = 1
interface InlineEditorState {
value: string
@@ -57,6 +62,7 @@ export function useBoundingBoxes(
const hoverTagIndex = ref<number | null>(null)
const bgImage = ref<HTMLImageElement | null>(null)
const inlineEditor = ref<InlineEditorState | null>(null)
const grid = ref(true)
const { width: containerWidth } = useElementSize(canvasContainer)
@@ -96,6 +102,88 @@ export function useBoundingBoxes(
return Math.max(0, Math.min(1, n))
}
function gridSpec() {
const stepX = Math.max(
GRID_PX,
Math.ceil(widthValue.value / MAX_GRID_CELLS)
)
const stepY = Math.max(
GRID_PX,
Math.ceil(heightValue.value / MAX_GRID_CELLS)
)
return {
fx: stepX / (widthValue.value || 1),
fy: stepY / (heightValue.value || 1)
}
}
function snapFraction(value: number, step: number) {
return step > 0 ? clampToCanvas(Math.round(value / step) * step) : value
}
function snapRegion(region: Region, mode: HitMode): Region {
if (!grid.value) return region
const { fx, fy } = gridSpec()
if (mode === 'move') {
return {
...region,
x: Math.min(snapFraction(region.x, fx), 1 - region.w),
y: Math.min(snapFraction(region.y, fy), 1 - region.h)
}
}
const snapLeft =
mode === 'draw' ||
mode === 'resize-l' ||
mode === 'resize-tl' ||
mode === 'resize-bl'
const snapRight =
mode === 'draw' ||
mode === 'resize-r' ||
mode === 'resize-tr' ||
mode === 'resize-br'
const snapTop =
mode === 'draw' ||
mode === 'resize-t' ||
mode === 'resize-tl' ||
mode === 'resize-tr'
const snapBottom =
mode === 'draw' ||
mode === 'resize-b' ||
mode === 'resize-bl' ||
mode === 'resize-br'
const x1 = snapLeft ? snapFraction(region.x, fx) : region.x
const y1 = snapTop ? snapFraction(region.y, fy) : region.y
const x2 = snapRight
? snapFraction(region.x + region.w, fx)
: region.x + region.w
const y2 = snapBottom
? snapFraction(region.y + region.h, fy)
: region.y + region.h
return {
...region,
x: x1,
y: y1,
w: Math.max(0, x2 - x1),
h: Math.max(0, y2 - y1)
}
}
function drawDots(ctx: CanvasRenderingContext2D, W: number, H: number) {
const { fx, fy } = gridSpec()
if (fx <= 0 || fy <= 0) return
ctx.save()
ctx.fillStyle = DOT_COLOR
ctx.beginPath()
for (let gx = 0; gx <= 1.0001; gx += fx) {
for (let gy = 0; gy <= 1.0001; gy += fy) {
ctx.moveTo(gx * W + DOT_RADIUS, gy * H)
ctx.arc(gx * W, gy * H, DOT_RADIUS, 0, Math.PI * 2)
}
}
ctx.fill()
ctx.restore()
}
function logicalSize() {
const el = canvasEl.value
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
@@ -146,6 +234,8 @@ export function useBoundingBoxes(
ctx.fillRect(0, 0, W, H)
}
if (grid.value) drawDots(ctx, W, H)
const showActive = focused.value || isNodeSelected.value
const aIdx = showActive ? activeIndex.value : -1
const order = state.value.regions
@@ -366,7 +456,7 @@ export function useBoundingBoxes(
const dx = mN.x - dragStartNorm.value.x
const dy = mN.y - dragStartNorm.value.y
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
state.value.regions[activeIndex.value] = nb
state.value.regions[activeIndex.value] = snapRegion(nb, dragMode.value)
requestDraw()
}
@@ -530,6 +620,23 @@ export function useBoundingBoxes(
watch(isNodeSelected, () => requestDraw())
watch([widthValue, heightValue], () => syncState())
watch(
litegraphNode,
(node) => {
const props = node?.properties as { bboxGrid?: unknown } | undefined
if (props && typeof props.bboxGrid === 'boolean')
grid.value = props.bboxGrid
},
{ immediate: true }
)
watch(grid, (enabled) => {
const props = litegraphNode.value?.properties as
| Record<string, unknown>
| undefined
if (props) props.bboxGrid = enabled
requestDraw()
})
const nodeOutputStore = useNodeOutputStore()
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
const node = litegraphNode.value
@@ -580,10 +687,44 @@ export function useBoundingBoxes(
}
img.src = url
}
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
let lastIncoming = ''
function applyIncomingBoxes() {
const node = litegraphNode.value
if (!node) return
const slot = node.findInputSlot('bboxes')
if (slot < 0 || !node.isInputConnected(slot)) {
lastIncoming = ''
return
}
const outputs = nodeOutputStore.getNodeOutputs(node) as
| NodeOutputWith<{ input_bboxes?: BoundingBox[] }>
| undefined
const incoming = outputs?.input_bboxes
if (!incoming?.length) return
const key = JSON.stringify(incoming)
if (key === lastIncoming) return
lastIncoming = key
state.value.regions = fromBoundingBoxes(
incoming,
widthValue.value,
heightValue.value
)
activeIndex.value = state.value.regions.length ? 0 : -1
syncState()
}
watch(
() => nodeOutputStore.nodeOutputs,
() => {
updateBgImage()
applyIncomingBoxes()
},
{ deep: true }
)
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
updateBgImage()
applyIncomingBoxes()
void nextTick(() => requestDraw())
onBeforeUnmount(() => {
@@ -608,6 +749,7 @@ export function useBoundingBoxes(
commitInlineEditor,
setActiveType,
clearAll,
syncState
syncState,
grid
}
}

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import type { EffectScope } from 'vue'
import { effectScope, ref, shallowRef } from 'vue'
@@ -13,17 +13,12 @@ afterEach(() => {
function setup(initial: string[]) {
const modelValue = ref(initial)
const container = shallowRef(document.createElement('div'))
const picker = shallowRef(document.createElement('input'))
const scope = effectScope()
scopes.push(scope)
const api = scope.run(() =>
usePaletteSwatchRow({ modelValue, container, picker })
)!
return { modelValue, container, picker, ...api }
const api = scope.run(() => usePaletteSwatchRow({ modelValue, container }))!
return { modelValue, container, ...api }
}
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
describe('usePaletteSwatchRow', () => {
it('appends a default color', () => {
const { modelValue, addColor } = setup(['#000000'])
@@ -37,31 +32,17 @@ describe('usePaletteSwatchRow', () => {
expect(modelValue.value).toEqual(['#a', '#c'])
})
it('seeds the picker input with the clicked color before opening it', () => {
const { picker, openPicker } = setup(['#112233'])
const click = vi.spyOn(picker.value!, 'click')
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#112233')
expect(click).toHaveBeenCalled()
})
it('falls back to white when the slot is empty', () => {
const { picker, openPicker } = setup([''])
openPicker(0, mouseEvent())
expect(picker.value!.value).toBe('#ffffff')
})
it('writes the picked color back to the open slot', () => {
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
openPicker(1, mouseEvent())
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
it('updates the color at an index', () => {
const { modelValue, updateAt } = setup(['#a', '#b'])
updateAt(1, '#123456')
expect(modelValue.value).toEqual(['#a', '#123456'])
})
it('ignores picker input when no slot is open', () => {
const { modelValue, onPickerInput } = setup(['#a'])
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
expect(modelValue.value).toEqual(['#a'])
it('ignores an update that does not change the color', () => {
const { modelValue, updateAt } = setup(['#a'])
const before = modelValue.value
updateAt(0, '#a')
expect(modelValue.value).toBe(before)
})
it('reorders via drag when the pointer crosses another swatch', () => {

View File

@@ -5,30 +5,16 @@ import { ref } from 'vue'
interface UsePaletteSwatchRowOptions {
modelValue: Ref<string[]>
container: Readonly<ShallowRef<HTMLDivElement | null>>
picker: Readonly<ShallowRef<HTMLInputElement | null>>
}
export function usePaletteSwatchRow({
modelValue,
container,
picker
container
}: UsePaletteSwatchRowOptions) {
const pickerIndex = ref<number | null>(null)
function openPicker(i: number, e: MouseEvent) {
e.stopPropagation()
pickerIndex.value = i
const el = picker.value
if (!el) return
el.value = modelValue.value[i] || '#ffffff'
el.click()
}
function onPickerInput(e: Event) {
const v = (e.target as HTMLInputElement).value
if (pickerIndex.value === null) return
function updateAt(i: number, value: string) {
if (modelValue.value[i] === value) return
const next = modelValue.value.slice()
next[pickerIndex.value] = v
next[i] = value
modelValue.value = next
}
@@ -105,8 +91,7 @@ export function usePaletteSwatchRow({
})
return {
openPicker,
onPickerInput,
updateAt,
remove,
addColor,
onPointerDown

View File

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

View File

@@ -1556,8 +1556,6 @@
"3DViewer": "3DViewer",
"Canvas Navigation": "Canvas Navigation",
"PlanCredits": "Plan & Credits",
"Members": "Members",
"PartnerNodes": "Allowlist",
"Vue Nodes": "Nodes 2.0",
"VueNodes": "Nodes 2.0",
"Nodes 2_0": "Nodes 2.0",
@@ -2173,7 +2171,8 @@
"descLabel": "description",
"textPlaceholder": "text to render (verbatim)",
"descPlaceholder": "description of this region",
"colors": "color_palette"
"colors": "color_palette",
"grid": "Grid"
},
"palette": {
"addColor": "Add a color",
@@ -2590,7 +2589,6 @@
"remaining": "remaining",
"refillsDate": "Refills {date}",
"refillsNextCycle": "Refills next cycle",
"refillPaused": "Refill paused",
"creditsUsed": "{used} used",
"creditsLeftOfTotal": "{remaining} left of {total}",
"monthlyUsageProgress": "{used} of {total} monthly credits used",
@@ -2848,21 +2846,6 @@
"updatePassword": "Update Password"
},
"workspacePanel": {
"billingStatus": {
"warning": {
"title": "Payment declined",
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated."
},
"paused": {
"title": "Subscription paused",
"body": "This workspace's subscription is paused. Update payment to resume.",
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method."
},
"updatePayment": "Update payment"
},
"charactersLeft": "{count} character left | {count} characters left",
"doubleClickToRename": "Double-click to rename",
"editWorkspaceImage": "Edit workspace image",
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
@@ -2874,56 +2857,18 @@
"dashboard": {
"placeholder": "Dashboard workspace settings"
},
"partnerNodes": {
"description": "Choose which partner nodes your team can use. Workflows with disabled nodes cannot be run.",
"searchPlaceholder": "Search partner nodes",
"selectAll": "Select all partner nodes",
"columns": {
"name": "Partner Node",
"partner": "Partner",
"lastModified": "Last modified"
},
"neverModified": "—",
"empty": "No partner nodes match your search.",
"selectedCount": "{count} node selected | {count} nodes selected",
"clearSelection": "Clear selection",
"autoEnableLabel": "When a new partner node is added, it should be:",
"autoEnabled": "auto-enabled",
"autoDisabled": "auto-disabled",
"loadError": "Failed to load partner nodes",
"updateError": "Failed to update partner nodes"
},
"members": {
"header": "Members",
"membersCount": "{count} of {maxSeats} members",
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
"membersCount": "Members ({count})",
"pending": "Pending",
"pendingCount": "Pending ({count})"
},
"membersUsage": "{count} of {max} total members.",
"columns": {
"email": "Email",
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"role": "Role",
"lastActivity": "Last activity",
"creditsUsed": "Credits used this month"
},
"activity": {
"justNow": "just now",
"minutesAgo": "{n} min ago",
"hoursAgo": "{n} hr ago",
"daysAgo": "{count} day ago | {count} days ago"
},
"outOfCredits": {
"title": "Out of credits",
"body": "Your team has used all its credits. Workflow runs are blocked until additional credits are added.",
"bodyWithReset": "Your team has used all its credits. Workflow runs are blocked until additional credits are added or the cycle resets on {date}.",
"dismiss": "Dismiss",
"addCredits": "Add credits"
"role": "Role"
},
"actions": {
"resendInvite": "Resend invite",
@@ -2943,7 +2888,6 @@
},
"menu": {
"editWorkspace": "Edit workspace details",
"renameWorkspace": "Rename Workspace",
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
@@ -2954,110 +2898,6 @@
"nameLabel": "Workspace name",
"save": "Save"
},
"requestMore": "Request more",
"planCredits": {
"tabs": {
"overview": "Overview",
"activity": "Activity",
"invoices": "Invoices"
}
},
"overview": {
"perMonth": "mo",
"renewsOn": "Renews on {date}",
"managePayment": "Manage payment",
"changePlan": "Change plan",
"usd": "USD",
"nextInvoice": "Next month invoice",
"seeMore": "See more",
"learnMore": "Learn more",
"pricingTable": "Partner Nodes pricing table",
"messageSupport": "Message support",
"snapshot": {
"user": "User",
"lastActivity": "Last activity",
"creditsUsed": "Credits used",
"topSpenders": "Top spenders",
"recentActivity": "Recent activity",
"empty": {
"title": "No credits used yet this month"
}
}
},
"activity": {
"columns": {
"date": "Date",
"user": "User",
"eventType": "Event type",
"eventDetails": "Event details",
"creditsUsed": "Credits used"
},
"hoverCard": {
"totalCreditsUsed": "Total credits used",
"lastActivity": "Last activity",
"partnerNodeUsed": "Partner node used"
},
"fullActivity": "Full activity",
"perUserHint": "Looking for total usage per user?",
"seeMembers": "See Members"
},
"invoices": {
"columns": {
"date": "Date",
"eventType": "Event type",
"price": "Price"
},
"fullHistory": "Full invoice history"
},
"autoReload": {
"title": "Credit auto-reload",
"subtitle": "Automatically add credits when your balance runs low, within an optional monthly budget.",
"enabled": "Enabled",
"disabled": "Disabled",
"edit": "Edit",
"empty": {
"body": "Keep your workflows running with auto-reloaded credits. Set a monthly budget so charges don't surprise you.",
"cta": "Set up auto-reload"
},
"tile": {
"label": "Auto-reload",
"whenBelow": "when credits drop below",
"monthlyBudget": "Monthly budget",
"resets": "Resets {date}",
"spent": "{amount} spent",
"leftOfBudget": "{left} left of {budget} budget",
"reloadsLeft": "{count} reload left | {count} reloads left",
"recentReload": "Recent reload: {date}",
"noRecentReload": "No reloads yet"
},
"badge": {
"paused": "Paused",
"off": "Off"
},
"dialog": {
"title": "Auto-reload credits",
"thresholdLabel": "When credits drop below:",
"amountLabel": "Add this amount of credits:",
"budgetToggleLabel": "Monthly budget",
"budgetToggleHint": "Limit how much is auto-reloaded per month",
"budgetPlaceholderCredits": "Enter an amount of credits",
"budgetPlaceholderUsd": "Enter an amount of dollars",
"minReload": "Minimum amount is {amount}",
"allowsReloads": "Allows {count} reload /mo | Allows {count} reloads /mo",
"credits": "Credits",
"usd": "USD",
"cancel": "Cancel",
"update": "Update"
}
},
"memberLimitDialog": {
"title": "Workspace is at the member limit",
"message": "All {count} seats are filled. To invite someone new, remove a member or request more seats."
},
"workflowQueuedDialog": {
"title": "Your workflow is queued",
"message": "Max workflow capacity reached. It'll start automatically as capacity opens up. If this happens often, you can also request for more capacity."
},
"leaveDialog": {
"title": "Leave this workspace?",
"message": "You won't be able to join again unless you contact the workspace owner.",
@@ -3076,12 +2916,12 @@
"error": "Failed to remove member"
},
"changeRoleDialog": {
"promoteTitle": "Make {name} an admin?",
"promoteTitle": "Make {name} an owner?",
"promoteIntro": "They'll be able to:",
"promotePermissionCredits": "Add additional credits",
"promotePermissionManage": "Manage members, payment methods, and workspace settings",
"promotePermissionRoles": "Promote and demote other admins (except the workspace owner).",
"promoteConfirm": "Make admin",
"promotePermissionRoles": "Promote and demote other owners (except the workspace creator).",
"promoteConfirm": "Make owner",
"demoteTitle": "Demote {name} to member?",
"demoteMessage": "They'll lose admin access.",
"demoteConfirm": "Demote to member",
@@ -3152,14 +2992,13 @@
"newWorkspace": "New workspace",
"namePlaceholder": "e.g. Marketing Team",
"createWorkspace": "Create workspace",
"nameValidationError": "Name must be 130 characters using letters, numbers, spaces, or common punctuation."
"nameValidationError": "Name must be 150 characters using letters, numbers, spaces, or common punctuation."
},
"workspaceSwitcher": {
"switchWorkspace": "Switch workspace",
"subscribe": "Subscribe",
"personal": "Personal",
"roleOwner": "Owner",
"roleAdmin": "Admin",
"roleMember": "Member",
"createWorkspace": "Create a workspace",
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",

View File

@@ -23,7 +23,6 @@ import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import { isDesktop, isNightly } from '@/platform/distribution/types'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { installBillingMockHarness } from '@/platform/workspace/dev/billingMockHarness'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import App from './App.vue'
@@ -34,10 +33,6 @@ import { i18n } from './i18n'
const isCloud = __DISTRIBUTION__ === 'cloud'
const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry)
// PREVIEW HARNESS (do not merge): no-op unless `?billingmock`. Must run before
// /api/features is fetched so the team-workspaces flag merge lands.
installBillingMockHarness()
// Load remote config before initializeApp() below, so getFirebaseConfig() resolves
// against the server's runtime values instead of the build-time defaults.
const { refreshRemoteConfig } =

View File

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

View File

@@ -1,11 +1,6 @@
<template>
<div
:class="
cn(
'@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5',
customClass
)
"
class="@container relative flex flex-col gap-6 rounded-2xl border border-interface-stroke bg-modal-panel-background px-6 py-5"
>
<Button
variant="muted-textonly"
@@ -24,10 +19,8 @@
</div>
<Skeleton v-if="isLoadingBalance" width="8rem" height="2rem" />
<div v-else class="flex items-baseline gap-2">
<i class="icon-[lucide--coins] size-4 self-center text-credit" />
<span class="text-2xl leading-none font-bold tabular-nums">{{
displayTotal
}}</span>
<i class="icon-[lucide--component] size-4 self-center text-credit" />
<span class="text-2xl leading-none font-bold">{{ displayTotal }}</span>
<span class="text-sm text-muted @max-[300px]:hidden">{{
$t('subscription.remaining')
}}</span>
@@ -80,15 +73,15 @@
width="5rem"
height="1rem"
/>
<span v-else class="text-muted tabular-nums @max-[300px]:hidden">
<span v-else class="text-muted @max-[300px]:hidden">
{{ $t('subscription.creditsUsed', { used: usedDisplay }) }}
</span>
<Skeleton v-if="isLoadingBalance" width="9rem" height="1rem" />
<span
v-else
class="flex items-center gap-1 font-bold text-text-primary tabular-nums"
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--coins] size-4 text-credit" />
<i class="icon-[lucide--component] size-4 text-credit" />
<span class="@max-[180px]:hidden">
{{
$t('subscription.creditsLeftOfTotal', {
@@ -125,7 +118,7 @@
variant="muted-textonly"
size="icon-sm"
:aria-label="$t('subscription.additionalCreditsInfo')"
class="flex cursor-help appearance-none items-center border-none bg-transparent p-0 text-muted transition-colors hover:text-text-primary"
class="text-muted"
>
<i class="icon-[lucide--info] size-4" />
</Button>
@@ -139,9 +132,9 @@
<Skeleton v-if="isLoadingBalance" width="3rem" height="1rem" />
<span
v-else
class="flex items-center gap-1 font-bold text-text-primary tabular-nums"
class="flex items-center gap-1 font-bold text-text-primary"
>
<i class="icon-[lucide--coins] size-4 text-credit" />
<i class="icon-[lucide--component] size-4 text-credit" />
{{ displayPrepaid }}
</span>
</div>
@@ -163,10 +156,15 @@
</Button>
<Button
v-else
:variant="isOutOfCredits ? 'inverted' : 'tertiary'"
:variant="isOutOfCredits ? 'inverted' : 'secondary'"
size="lg"
class="w-full font-normal"
:disabled="isPaused"
:class="
cn(
'w-full font-normal',
!isOutOfCredits &&
'bg-interface-menu-component-surface-selected text-text-primary'
)
"
@click="handleAddCredits"
>
{{ $t('subscription.addCredits') }}
@@ -180,7 +178,6 @@ import { cn } from '@comfyorg/tailwind-utils'
import { useEventListener } from '@vueuse/core'
import Skeleton from 'primevue/skeleton'
import { computed, onMounted } from 'vue'
import type { HTMLAttributes } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatCredits } from '@/base/credits/comfyCredits'
@@ -200,17 +197,15 @@ import { consumePendingTopup } from '@/platform/telemetry/topupTracker'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useDialogService } from '@/services/dialogService'
const { zeroState = false, class: customClass } = defineProps<{
const { zeroState = false } = defineProps<{
/** Forces the zero-credit display (e.g. unsubscribed / member view). */
zeroState?: boolean
class?: HTMLAttributes['class']
}>()
const { locale, t } = useI18n()
const {
subscription,
subscriptionStatus,
balance,
isActiveSubscription,
isFreeTier,
@@ -232,11 +227,6 @@ const { wrapWithErrorHandlingAsync } = useErrorHandling()
const dialogService = useDialogService()
const telemetry = useTelemetry()
// Paused (failed payment): the plan still exists but refills are frozen, so the
// tile drops the out-of-credits messaging, shows an empty bar, and disables the
// top-up action — adding credits doesn't help until payment is resolved.
const isPaused = computed(() => subscriptionStatus.value === 'paused')
const tierKey = computed(() => {
const tier = subscription.value?.tier
if (!tier) return DEFAULT_TIER_KEY
@@ -249,13 +239,12 @@ const monthlyTotalCredits = computed<number | null>(() => {
return getTierCredits(tierKey.value)
})
const usage = computed(() => {
const base = computeMonthlyUsage(
const usage = computed(() =>
computeMonthlyUsage(
monthlyBonusCreditsValue.value,
monthlyTotalCredits.value ?? 0
)
return isPaused.value ? { ...base, used: 0, usedFraction: 0 } : base
})
)
const refillsDateShort = computed(() => {
const raw = subscription.value?.renewalDate
@@ -268,12 +257,11 @@ const refillsDateShort = computed(() => {
const hasRefillsDate = computed(() => refillsDateShort.value !== '')
const refillsLabel = computed(() => {
if (isPaused.value) return t('subscription.refillPaused')
return hasRefillsDate.value
const refillsLabel = computed(() =>
hasRefillsDate.value
? t('subscription.refillsDate', { date: refillsDateShort.value })
: t('subscription.refillsNextCycle')
})
)
const formatCreditCount = (value: number) =>
formatCredits({
@@ -325,7 +313,6 @@ const showActionButton = computed(
const isMonthlyDepleted = computed(
() =>
!isPaused.value &&
showBar.value &&
!isLoadingBalance.value &&
balance.value != null &&

View File

@@ -30,90 +30,6 @@ describe('preservedQueryManager', () => {
expect(sessionStorage.getItem('Comfy.PreservedQuery.template')).toBeTruthy()
})
it('merges newly captured keys into the payload when merge is set', () => {
capturePreservedQuery(
NAMESPACE,
{ template: 'flux' },
['template', 'source', 'mode'],
{ merge: true }
)
capturePreservedQuery(
NAMESPACE,
{ source: 'custom' },
['template', 'source', 'mode'],
{ merge: true }
)
const merged = mergePreservedQueryIntoQuery(NAMESPACE)
expect(merged).toEqual({ template: 'flux', source: 'custom' })
})
it('replaces the whole payload on capture by default', () => {
capturePreservedQuery(NAMESPACE, { template: 'flux', source: 'custom' }, [
'template',
'source',
'mode'
])
capturePreservedQuery(NAMESPACE, { template: 'sdxl' }, [
'template',
'source',
'mode'
])
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toEqual({
template: 'sdxl'
})
})
it('leaves the payload untouched when a default capture has no valid values', () => {
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
capturePreservedQuery(NAMESPACE, { template: '' }, ['template'])
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBe('flux')
})
it('captures the first non-empty string element of an array-valued param', () => {
capturePreservedQuery(NAMESPACE, { template: ['', 'flux', 'sdxl'] }, [
'template'
])
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBe('flux')
})
it('does not stash empty, null, or all-junk array values', () => {
capturePreservedQuery(
NAMESPACE,
{ template: '', source: null, mode: ['', null] },
['template', 'source', 'mode']
)
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBeUndefined()
expect(getPreservedQueryParam(NAMESPACE, 'source')).toBeUndefined()
expect(getPreservedQueryParam(NAMESPACE, 'mode')).toBeUndefined()
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toBeUndefined()
})
it('removes a preserved key on empty value when merge is set', () => {
capturePreservedQuery(
NAMESPACE,
{ template: 'flux', source: 'custom' },
['template', 'source'],
{ merge: true }
)
capturePreservedQuery(NAMESPACE, { template: '' }, ['template', 'source'], {
merge: true
})
expect(getPreservedQueryParam(NAMESPACE, 'template')).toBeUndefined()
expect(mergePreservedQueryIntoQuery(NAMESPACE)).toEqual({
source: 'custom'
})
})
it('reads a preserved query param by key', () => {
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
@@ -162,16 +78,6 @@ describe('preservedQueryManager', () => {
expect(merged).toBeUndefined()
})
it('overwrites an array-valued live query key with the stashed string', () => {
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])
const merged = mergePreservedQueryIntoQuery(NAMESPACE, {
template: ['existing', 'other']
})
expect(merged).toEqual({ template: 'flux' })
})
it('clears cached payload', () => {
capturePreservedQuery(NAMESPACE, { template: 'flux' }, ['template'])

View File

@@ -4,12 +4,7 @@ const STORAGE_PREFIX = 'Comfy.PreservedQuery.'
const preservedQueries = new Map<string, Record<string, string>>()
const readQueryParam = (value: unknown): string | undefined => {
if (typeof value === 'string') return value
if (!Array.isArray(value)) return undefined
return value.find(
(entry: unknown): entry is string =>
typeof entry === 'string' && entry !== ''
)
return typeof value === 'string' ? value : undefined
}
const getStorageKey = (namespace: string) => `${STORAGE_PREFIX}${namespace}`
@@ -70,65 +65,25 @@ export const hydratePreservedQuery = (namespace: string) => {
}
}
/**
* By default each capture replaces the namespace stash with the values present
* in the given query. With `merge`, values are merged into the existing stash
* and a key supplied with an empty value clears its stashed entry — for
* namespaces where the stash, not the URL, is the surviving carrier.
*/
export const capturePreservedQuery = (
namespace: string,
query: LocationQuery,
keys: string[],
{ merge = false }: { merge?: boolean } = {}
keys: string[]
) => {
if (!merge) {
const payload: Record<string, string> = {}
keys.forEach((key) => {
const value = readQueryParam(query[key])
if (value) {
payload[key] = value
}
})
if (Object.keys(payload).length === 0) {
return
}
preservedQueries.set(namespace, payload)
writeToStorage(namespace, payload)
return
}
hydratePreservedQuery(namespace)
const payload: Record<string, string> = {
...(preservedQueries.get(namespace) ?? {})
}
let changed = false
const payload: Record<string, string> = {}
keys.forEach((key) => {
if (!Object.hasOwn(query, key)) return
const value = readQueryParam(query[key])
if (value) {
payload[key] = value
changed = true
return
}
if (key in payload) {
delete payload[key]
changed = true
}
})
if (!changed) {
if (Object.keys(payload).length === 0) {
return
}
if (Object.keys(payload).length === 0) {
preservedQueries.delete(namespace)
} else {
preservedQueries.set(namespace, payload)
}
preservedQueries.set(namespace, payload)
writeToStorage(namespace, payload)
}

View File

@@ -1,196 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Router, RouterHistory } from 'vue-router'
import { createMemoryHistory, createRouter } from 'vue-router'
import {
clearPreservedQuery,
getPreservedQueryParam
} from '@/platform/navigation/preservedQueryManager'
import { installPreservedQueryTracker } from '@/platform/navigation/preservedQueryTracker'
const STRIPPED_NAMESPACE = 'test_strip'
const SECOND_STRIPPED_NAMESPACE = 'test_strip_b'
const PLAIN_NAMESPACE = 'test_plain'
const strippedDefinition = {
namespace: STRIPPED_NAMESPACE,
keys: ['one_time_code'],
stripAfterCapture: true
}
const plainDefinition = {
namespace: PLAIN_NAMESPACE,
keys: ['plain_code', 'plain_source']
}
function createTestRouter(
history: RouterHistory = createMemoryHistory()
): Router {
return createRouter({
history,
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
})
}
describe('installPreservedQueryTracker', () => {
beforeEach(() => {
sessionStorage.clear()
clearPreservedQuery(STRIPPED_NAMESPACE)
clearPreservedQuery(SECOND_STRIPPED_NAMESPACE)
clearPreservedQuery(PLAIN_NAMESPACE)
})
it('strips marked keys from the URL while preserving other query and hash', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [strippedDefinition])
await router.push('/?one_time_code=otc_abc123&keep=a+b#frag')
expect(router.currentRoute.value.fullPath).toBe('/?keep=a+b#frag')
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
'otc_abc123'
)
})
it('keeps params of non-strip namespaces in the URL and still captures them', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [plainDefinition])
await router.push('/?plain_code=alpha&plain_source=beta')
expect(router.currentRoute.value.fullPath).toBe(
'/?plain_code=alpha&plain_source=beta'
)
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_code')).toBe('alpha')
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_source')).toBe('beta')
})
it('replaces a non-strip namespace stash on later captures', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [plainDefinition])
await router.push('/?plain_code=alpha&plain_source=beta')
await router.push('/?plain_code=gamma')
expect(getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_code')).toBe('gamma')
expect(
getPreservedQueryParam(PLAIN_NAMESPACE, 'plain_source')
).toBeUndefined()
})
it('navigates exactly once when no strip-marked keys are present', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [strippedDefinition])
let completedNavigations = 0
router.afterEach(() => {
completedNavigations++
})
await router.push('/?keep=1')
expect(router.currentRoute.value.fullPath).toBe('/?keep=1')
expect(completedNavigations).toBe(1)
})
it('scrubs empty and null values from the URL without stashing them', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [strippedDefinition])
await router.push('/?one_time_code=')
expect(router.currentRoute.value.fullPath).toBe('/')
expect(
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
).toBeUndefined()
await router.push('/?one_time_code')
expect(router.currentRoute.value.fullPath).toBe('/')
expect(
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
).toBeUndefined()
})
it('clears a stale stripped value when the URL supplies an empty value', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [strippedDefinition])
await router.push('/?one_time_code=otc_abc123')
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
'otc_abc123'
)
await router.push('/?one_time_code=')
expect(router.currentRoute.value.fullPath).toBe('/')
expect(
getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')
).toBeUndefined()
})
it('stashes the first value of a repeated param and cleans the URL', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [strippedDefinition])
await router.push('/?one_time_code=otc_A&one_time_code=otc_B')
expect(router.currentRoute.value.fullPath).toBe('/')
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
'otc_A'
)
})
it('strips keys of multiple marked namespaces in a single redirect', async () => {
const router = createTestRouter()
let navigationAttempts = 0
router.beforeEach((_to, _from, next) => {
navigationAttempts++
next()
})
installPreservedQueryTracker(router, [
strippedDefinition,
{
namespace: SECOND_STRIPPED_NAMESPACE,
keys: ['second_code'],
stripAfterCapture: true
}
])
await router.push('/?one_time_code=otc_x&second_code=sc_y&keep=1')
expect(router.currentRoute.value.fullPath).toBe('/?keep=1')
expect(navigationAttempts).toBe(2)
expect(getPreservedQueryParam(STRIPPED_NAMESPACE, 'one_time_code')).toBe(
'otc_x'
)
expect(
getPreservedQueryParam(SECOND_STRIPPED_NAMESPACE, 'second_code')
).toBe('sc_y')
})
it('keeps the prior history entry reachable after the strip redirect', async () => {
const router = createTestRouter()
installPreservedQueryTracker(router, [strippedDefinition])
await router.push('/start')
await router.push('/?one_time_code=otc_abc123')
expect(router.currentRoute.value.fullPath).toBe('/')
router.go(-1)
await vi.waitFor(() =>
expect(router.currentRoute.value.fullPath).toBe('/start')
)
})
it('keeps replace navigation from adding a back target', async () => {
const history = createMemoryHistory()
const router = createTestRouter(history)
installPreservedQueryTracker(router, [strippedDefinition])
await router.push('/start')
await router.replace('/?one_time_code=otc_abc123')
expect(router.currentRoute.value.fullPath).toBe('/')
router.go(-1)
expect(history.location).toBe('/')
})
})

View File

@@ -5,48 +5,25 @@ import {
hydratePreservedQuery
} from '@/platform/navigation/preservedQueryManager'
interface PreservedQueryDefinition {
namespace: string
keys: string[]
/**
* When set, keys present in the query are removed from the client-side URL
* before navigation completes. Later guards, afterEach hooks, and views must
* read a strip-marked key from the preserved-query stash instead of
* route.query or fullPath. Because the stash is the only carrier after
* stripping, captures for the namespace merge into the existing stash and an
* explicitly empty value clears the stashed key; non-strip namespaces keep
* replace-on-capture semantics.
*/
stripAfterCapture?: boolean
}
export const installPreservedQueryTracker = (
router: Router,
definitions: PreservedQueryDefinition[]
definitions: Array<{ namespace: string; keys: string[] }>
) => {
const trackedDefinitions = definitions.map((definition) => ({
...definition
}))
router.beforeEach((to, _from, next) => {
const queryKeys = new Set(Object.keys(to.query))
const keysToStrip = new Set<string>()
definitions.forEach(({ namespace, keys, stripAfterCapture }) => {
trackedDefinitions.forEach(({ namespace, keys }) => {
hydratePreservedQuery(namespace)
const presentKeys = keys.filter((key) => queryKeys.has(key))
if (presentKeys.length === 0) return
capturePreservedQuery(namespace, to.query, keys, {
merge: stripAfterCapture
})
if (stripAfterCapture) {
presentKeys.forEach((key) => keysToStrip.add(key))
const shouldCapture = keys.some((key) => queryKeys.has(key))
if (shouldCapture) {
capturePreservedQuery(namespace, to.query, keys)
}
})
if (keysToStrip.size === 0) {
next()
return
}
const cleanedQuery = { ...to.query }
keysToStrip.forEach((key) => delete cleanedQuery[key])
next({ path: to.path, query: cleanedQuery, hash: to.hash })
next()
})
}

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ import {
} from '@/platform/settings/settingStore'
import type { SettingTreeNode } from '@/platform/settings/settingStore'
import type { SettingPanelType, SettingParams } from '@/platform/settings/types'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import type { NavGroupData } from '@/types/navTypes'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
@@ -28,15 +27,13 @@ const CATEGORY_ICONS: Record<string, string> = {
keybinding: 'icon-[lucide--keyboard]',
LiteGraph: 'icon-[lucide--workflow]',
'Mask Editor': 'icon-[lucide--pen-tool]',
Members: 'icon-[lucide--users]',
Other: 'icon-[lucide--ellipsis]',
PartnerNodes: 'icon-[lucide--shield-check]',
PlanCredits: 'icon-[lucide--receipt-text]',
PlanCredits: 'icon-[lucide--credit-card]',
secrets: 'icon-[lucide--key-round]',
'server-config': 'icon-[lucide--server]',
subscription: 'icon-[lucide--credit-card]',
user: 'icon-[lucide--user]',
workspace: 'icon-[lucide--receipt-text]'
workspace: 'icon-[lucide--building-2]'
}
interface SettingPanelItem {
@@ -57,7 +54,6 @@ export function useSettingUI(
const { flags } = useFeatureFlags()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const { isActiveSubscription, type: billingType } = useBillingContext()
const { permissions } = useWorkspaceUI()
const teamWorkspacesEnabled = computed(
() => isCloud && flags.teamWorkspacesEnabled
@@ -179,42 +175,16 @@ export function useSettingUI(
)
}
// Workspace panels: only available on cloud with team workspaces enabled.
// The old single "Workspace" panel is split into three sidebar entries; the
// Plan & Credits entry keeps the 'workspace' key so existing deep links land.
const planCreditsPanel: SettingPanelItem = {
// Workspace panel: only available on cloud with team workspaces enabled
const workspacePanel: SettingPanelItem = {
node: {
key: 'workspace',
label: 'PlanCredits',
label: 'Workspace',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/PlanCreditsPanelContent.vue')
)
}
const membersPanel: SettingPanelItem = {
node: {
key: 'workspace-members',
label: 'Members',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/WorkspaceMembersPanelContent.vue')
)
}
const partnerNodesPanel: SettingPanelItem = {
node: {
key: 'workspace-partner-nodes',
label: 'PartnerNodes',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/PartnerNodesPanelContent.vue')
import('@/platform/workspace/components/dialogs/settings/WorkspacePanelContent.vue')
)
}
@@ -222,12 +192,6 @@ export function useSettingUI(
() => teamWorkspacesEnabled.value && isLoggedIn.value
)
// Partner-node governance is Owner/Admin-only; Members never see the tab.
const shouldShowPartnerNodesPanel = computed(
() =>
shouldShowWorkspacePanel.value && permissions.value.canManagePartnerNodes
)
const secretsPanel: SettingPanelItem = {
node: {
key: 'secrets',
@@ -281,10 +245,7 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
...(shouldShowWorkspacePanel.value
? [planCreditsPanel, membersPanel]
: []),
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel] : []),
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
keybindingPanel,
extensionPanel,
...(isDesktop ? [serverConfigPanel] : []),
@@ -334,14 +295,8 @@ export function useSettingUI(
key: 'workspace',
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value
? [planCreditsPanel.node, membersPanel.node]
: []),
...(shouldShowPartnerNodesPanel.value ? [partnerNodesPanel.node] : []),
// The legacy per-account Credits panel is redundant once the workspace
// Plan & Credits panel is present, which now owns the credit balance.
...(!shouldShowWorkspacePanel.value &&
isLoggedIn.value &&
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
...(isLoggedIn.value &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
: [])

View File

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

View File

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

View File

@@ -1,88 +0,0 @@
import axios from 'axios'
import { attachUnifiedRemintInterceptor } from '@/platform/auth/unified/remintRetry'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
/** A partner (paid-API) node the workspace can allow or block. */
export interface PartnerNode {
id: string
name: string
partner: string
/** ISO date of the last governance change, or null if never modified. */
last_modified: string | null
enabled: boolean
}
export interface PartnerNodesResponse {
partner_nodes: PartnerNode[]
/** Workspace default applied to newly added partner nodes. */
auto_enable_new: boolean
}
interface SetEnabledPayload {
enabled: boolean
}
interface BulkSetEnabledPayload {
node_ids: string[]
enabled: boolean
}
interface SetAutoEnablePayload {
auto_enable_new: boolean
}
const partnerNodesApiClient = axios.create({
headers: { 'Content-Type': 'application/json' }
})
attachUnifiedRemintInterceptor(partnerNodesApiClient)
async function authHeader() {
return useAuthStore().getAuthHeaderOrThrow()
}
export const partnerNodesApi = {
/** GET /api/workspace/partner-nodes */
async list(): Promise<PartnerNodesResponse> {
const headers = await authHeader()
const response = await partnerNodesApiClient.get<PartnerNodesResponse>(
api.apiURL('/workspace/partner-nodes'),
{ headers }
)
return response.data
},
/** PATCH /api/workspace/partner-nodes/:id */
async setEnabled(nodeId: string, enabled: boolean): Promise<void> {
const headers = await authHeader()
const payload: SetEnabledPayload = { enabled }
await partnerNodesApiClient.patch(
api.apiURL(`/workspace/partner-nodes/${nodeId}`),
payload,
{ headers }
)
},
/** PATCH /api/workspace/partner-nodes (bulk) */
async setEnabledBulk(nodeIds: string[], enabled: boolean): Promise<void> {
const headers = await authHeader()
const payload: BulkSetEnabledPayload = { node_ids: nodeIds, enabled }
await partnerNodesApiClient.patch(
api.apiURL('/workspace/partner-nodes'),
payload,
{ headers }
)
},
/** PUT /api/workspace/partner-nodes/settings */
async setAutoEnableNew(autoEnableNew: boolean): Promise<void> {
const headers = await authHeader()
const payload: SetAutoEnablePayload = { auto_enable_new: autoEnableNew }
await partnerNodesApiClient.put(
api.apiURL('/workspace/partner-nodes/settings'),
payload,
{ headers }
)
}
}

View File

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

View File

@@ -3,7 +3,7 @@
<!-- Loading state while subscription is being set up -->
<div
v-if="isSettingUp"
class="rounded-2xl border border-interface-stroke/60 p-6"
class="rounded-2xl border border-interface-stroke p-6"
>
<div class="flex items-center gap-2 py-4 text-muted-foreground">
<i class="pi pi-spin pi-spinner" />
@@ -14,7 +14,7 @@
<!-- Billing data still loading: avoid rendering a false Free/$0 plan -->
<div
v-else-if="isLoading && !subscription"
class="rounded-2xl border border-interface-stroke/60 p-6"
class="rounded-2xl border border-interface-stroke p-6"
>
<div class="flex items-center gap-2 py-4 text-muted-foreground">
<i class="pi pi-spin pi-spinner" />
@@ -25,7 +25,7 @@
<!-- Billing fetch failed: offer retry rather than a misleading Free plan -->
<div
v-else-if="error && !subscription"
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke/60 p-6"
class="flex flex-col items-start gap-3 rounded-2xl border border-interface-stroke p-6"
>
<div class="flex items-center gap-2 text-text-secondary">
<i class="pi pi-exclamation-circle text-danger" />
@@ -67,7 +67,7 @@
</div>
</div>
<div class="rounded-2xl border border-interface-stroke/60 p-6">
<div class="rounded-2xl border border-interface-stroke p-6">
<div>
<div
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-2"

View File

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

View File

@@ -1,304 +0,0 @@
<template>
<div
class="flex w-132 max-w-full flex-col rounded-2xl border border-border-default bg-base-background"
>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<h2 class="m-0 text-sm font-normal text-base-foreground">
{{ $t('workspacePanel.autoReload.dialog.title') }}
</h2>
<button
class="cursor-pointer rounded-sm border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-base-foreground"
:aria-label="$t('g.close')"
@click="onClose"
>
<i class="pi pi-times size-4" />
</button>
</div>
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.dialog.thresholdLabel') }}
</label>
<div :class="fieldClass">
<i class="icon-[lucide--coins] size-4 shrink-0 text-credit" />
<input
v-model="thresholdModel"
inputmode="numeric"
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none"
/>
</div>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.dialog.amountLabel') }}
</label>
<div
:class="cn(fieldClass, reloadBelowMinimum && 'ring-1 ring-red-500')"
>
<i
v-if="unit === 'credits'"
class="icon-[lucide--coins] size-4 shrink-0 text-credit"
/>
<span v-else class="shrink-0 text-sm text-muted-foreground">$</span>
<input
v-model="reloadModel"
inputmode="numeric"
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none"
/>
<span
class="flex shrink-0 items-center gap-1 text-sm text-muted-foreground tabular-nums"
>
<template v-if="unit === 'credits'"
> {{ reloadCostLabel }}</template
>
<template v-else>
<i class="icon-[lucide--coins] size-3.5 text-muted-foreground" />
{{ reloadCreditsLabel }}
</template>
</span>
</div>
<p v-if="reloadError" class="m-0 text-xs text-red-500">
{{ reloadError }}
</p>
</div>
</div>
<div class="flex flex-col gap-2 border-t border-border-default p-4">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-base-foreground">
{{ $t('workspacePanel.autoReload.dialog.budgetToggleLabel') }}
</span>
<span class="flex items-center gap-2 text-sm text-muted-foreground">
{{
budgetEnabled
? $t('workspacePanel.autoReload.enabled')
: $t('workspacePanel.autoReload.disabled')
}}
<Switch v-model="budgetEnabled" />
</span>
</div>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.dialog.budgetToggleHint') }}
</p>
<div :class="cn(fieldClass, !budgetEnabled && 'opacity-50')">
<i
v-if="unit === 'credits'"
class="icon-[lucide--coins] size-4 shrink-0 text-credit"
/>
<span v-else class="shrink-0 text-sm text-muted-foreground">$</span>
<input
v-model="budgetModel"
:disabled="!budgetEnabled"
inputmode="numeric"
:placeholder="budgetPlaceholder"
class="w-full min-w-0 border-none bg-transparent text-sm text-base-foreground tabular-nums outline-none disabled:cursor-not-allowed"
/>
<span
v-if="budgetEnabled && budgetCents > 0"
class="flex shrink-0 items-center gap-1 text-sm text-muted-foreground tabular-nums"
>
<template v-if="unit === 'credits'"> {{ budgetUsdLabel }}</template>
<template v-else>
<i class="icon-[lucide--coins] size-3.5 text-muted-foreground" />
{{ budgetCreditsLabel }}
</template>
</span>
</div>
<p
v-if="budgetEnabled && budgetCents > 0"
class="m-0 text-xs text-muted-foreground"
>
{{ allowsReloadsLabel }}
</p>
</div>
<div
class="flex items-center justify-between border-t border-border-default p-4"
>
<ToggleGroup
type="single"
:model-value="unit"
class="rounded-lg bg-secondary-background p-0.5"
@update:model-value="onUnitChange"
>
<ToggleGroupItem
v-for="option in unitOptions"
:key="option"
:value="option"
size="lg"
>
{{ $t(`workspacePanel.autoReload.dialog.${option}`) }}
</ToggleGroupItem>
</ToggleGroup>
<div class="flex items-center gap-4">
<Button variant="muted-textonly" @click="onClose">
{{ $t('workspacePanel.autoReload.dialog.cancel') }}
</Button>
<Button
variant="secondary"
size="lg"
:disabled="!canUpdate"
@click="onUpdate"
>
{{ $t('workspacePanel.autoReload.dialog.update') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import {
centsToCredits,
creditsToCents,
creditsToUsd,
usdToCents,
usdToCredits
} from '@/base/credits/comfyCredits'
import Button from '@/components/ui/button/Button.vue'
import Switch from '@/components/ui/switch/Switch.vue'
import ToggleGroup from '@/components/ui/toggle-group/ToggleGroup.vue'
import ToggleGroupItem from '@/components/ui/toggle-group/ToggleGroupItem.vue'
import { useAutoReload } from '@/platform/workspace/composables/useAutoReload'
import { useDialogStore } from '@/stores/dialogStore'
import { cn } from '@comfyorg/tailwind-utils'
const { t, n: fmtNumber } = useI18n()
const dialogStore = useDialogStore()
const { config, save } = useAutoReload()
type Unit = 'credits' | 'usd'
const unitOptions: Unit[] = ['credits', 'usd']
const unit = ref<Unit>('credits')
// reka's single ToggleGroup can emit '' on re-click (deselect); ignore that so a
// unit always stays selected.
function onUnitChange(value: unknown) {
if (value === 'credits' || value === 'usd') unit.value = value
}
const thresholdCredits = ref(config.thresholdCredits || 1000)
const reloadCredits = ref(config.reloadCredits || 5000)
const budgetEnabled = ref(config.monthlyBudgetCents != null)
const budgetCents = ref(config.monthlyBudgetCents ?? 0)
const fieldClass =
'flex items-center gap-2 rounded-lg bg-secondary-background px-3 py-2.5'
const fmtInt = (value: number) => fmtNumber(value, { maximumFractionDigits: 0 })
const fmtUsd = (cents: number) =>
(cents / 100).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
})
const parseNum = (raw: string) => {
const parsed = Number(raw.replace(/[^0-9.]/g, ''))
return Number.isFinite(parsed) ? parsed : 0
}
// A 0 value renders as an empty field (not "0") so backspacing clears it.
const thresholdModel = computed({
get: () =>
thresholdCredits.value === 0 ? '' : fmtInt(thresholdCredits.value),
set: (value) => (thresholdCredits.value = Math.round(parseNum(value)))
})
const reloadModel = computed({
get: () => {
if (reloadCredits.value === 0) return ''
return unit.value === 'credits'
? fmtInt(reloadCredits.value)
: fmtInt(creditsToUsd(reloadCredits.value))
},
set: (value) => {
const parsed = parseNum(value)
reloadCredits.value =
unit.value === 'credits' ? Math.round(parsed) : usdToCredits(parsed)
}
})
const reloadCostLabel = computed(() =>
fmtUsd(creditsToCents(reloadCredits.value))
)
const reloadCreditsLabel = computed(() => fmtInt(reloadCredits.value))
const budgetModel = computed({
get: () => {
if (budgetCents.value === 0) return ''
return unit.value === 'credits'
? fmtInt(centsToCredits(budgetCents.value))
: fmtInt(Math.round(budgetCents.value / 100))
},
set: (value) => {
const parsed = parseNum(value)
budgetCents.value =
unit.value === 'credits'
? creditsToCents(Math.round(parsed))
: usdToCents(parsed)
}
})
const budgetUsdLabel = computed(() => fmtUsd(budgetCents.value))
const budgetCreditsLabel = computed(() =>
fmtInt(centsToCredits(budgetCents.value))
)
const budgetPlaceholder = computed(() =>
unit.value === 'credits'
? t('workspacePanel.autoReload.dialog.budgetPlaceholderCredits')
: t('workspacePanel.autoReload.dialog.budgetPlaceholderUsd')
)
const allowsReloadsLabel = computed(() => {
const reloads =
reloadCredits.value > 0
? Math.floor(centsToCredits(budgetCents.value) / reloadCredits.value)
: 0
return t('workspacePanel.autoReload.dialog.allowsReloads', reloads)
})
// The reload amount must be worth at least $5 (its credit equivalent).
const MIN_RELOAD_CENTS = 500
const MIN_RELOAD_CREDITS = usdToCredits(5)
const reloadBelowMinimum = computed(
() => reloadCredits.value > 0 && reloadCredits.value < MIN_RELOAD_CREDITS
)
const reloadError = computed(() => {
if (!reloadBelowMinimum.value) return ''
const amount =
unit.value === 'credits'
? fmtInt(MIN_RELOAD_CREDITS)
: fmtUsd(MIN_RELOAD_CENTS)
return t('workspacePanel.autoReload.dialog.minReload', { amount })
})
const canUpdate = computed(
() =>
thresholdCredits.value > 0 &&
reloadCredits.value >= MIN_RELOAD_CREDITS &&
(!budgetEnabled.value || budgetCents.value > 0)
)
function onClose() {
dialogStore.closeDialog({ key: 'auto-reload' })
}
function onUpdate() {
if (!canUpdate.value) return
save({
thresholdCredits: thresholdCredits.value,
reloadCredits: reloadCredits.value,
monthlyBudgetCents:
budgetEnabled.value && budgetCents.value > 0 ? budgetCents.value : null
})
onClose()
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,223 +0,0 @@
<template>
<div
class="flex flex-col gap-4 rounded-2xl border border-interface-stroke/60 p-6"
>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-base-foreground">
{{ $t('workspacePanel.autoReload.title') }}
</span>
<span class="max-w-md text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.subtitle') }}
</span>
</div>
<div v-if="isConfigured" class="flex shrink-0 items-center gap-3">
<span class="flex items-center gap-2 text-sm text-muted-foreground">
{{ enabledLabel }}
<Switch :model-value="isEnabled" @update:model-value="setEnabled" />
</span>
<Button variant="secondary" size="lg" @click="openConfig">
{{ $t('workspacePanel.autoReload.edit') }}
</Button>
</div>
</div>
<!-- Empty / not-set-up state same one-column grid as the configured tile
so both sit at the top tiles' width. -->
<div v-if="!isConfigured" class="grid grid-cols-2 gap-4">
<div
class="flex flex-col gap-3 rounded-xl bg-modal-panel-background px-6 py-5"
>
<p class="m-0 text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.empty.body') }}
</p>
<Button variant="tertiary" size="lg" class="w-full" @click="openConfig">
{{ $t('workspacePanel.autoReload.empty.cta') }}
</Button>
</div>
</div>
<!-- Configured tile constrained to one column of the same grid the top
tiles use, so it matches their width (empty second column left open). -->
<div v-else class="grid grid-cols-2 gap-4">
<div
:class="
cn(
'flex flex-col gap-4 rounded-xl bg-modal-panel-background px-6 py-5 transition-opacity',
!isEnabled && 'opacity-50'
)
"
>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">
{{ $t('workspacePanel.autoReload.tile.label') }}
</span>
<StatusBadge v-if="badge" :label="badge" severity="secondary" />
</div>
<p
:class="
cn(
'm-0 flex items-center gap-1.5 text-sm text-muted-foreground',
isPaused && 'opacity-50'
)
"
>
<i class="icon-[lucide--coins] size-4 text-credit" />
<span class="text-lg font-semibold text-base-foreground tabular-nums">
{{ reloadCreditsLabel }}
</span>
{{ $t('workspacePanel.autoReload.tile.whenBelow') }}
<span class="font-semibold text-base-foreground tabular-nums">
{{ thresholdLabel }}
</span>
</p>
<div v-if="hasBudget" class="flex flex-col gap-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">
{{ $t('workspacePanel.autoReload.tile.monthlyBudget') }}
</span>
<span class="text-muted-foreground">{{ resetsLabel }}</span>
</div>
<ProgressBar :value="budgetUsedFraction" />
<div class="flex items-start justify-between text-sm">
<span class="text-muted-foreground tabular-nums">
{{ spentLabel }}
</span>
<div class="flex flex-col items-end gap-0.5">
<span class="font-medium text-base-foreground tabular-nums">
{{ leftLabel }}
</span>
<span :class="cn('tabular-nums', reloadsLeftClass)">
{{ reloadsLeftLabel }}
</span>
</div>
</div>
</div>
<div
class="flex items-center justify-between border-t border-interface-stroke/60 pt-3 text-sm text-muted-foreground"
>
<span class="tabular-nums">{{ recentReloadLabel }}</span>
<span
v-if="config.lastReload"
class="flex items-center gap-1.5 tabular-nums"
>
<i class="icon-[lucide--coins] size-4 text-credit" />
<span class="text-base-foreground">{{
lastReloadCreditsLabel
}}</span>
<span>·</span>
<span class="text-base-foreground">{{ lastReloadCostLabel }}</span>
</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { creditsToCents } from '@/base/credits/comfyCredits'
import StatusBadge from '@/components/common/StatusBadge.vue'
import Button from '@/components/ui/button/Button.vue'
import Switch from '@/components/ui/switch/Switch.vue'
import ProgressBar from '@/platform/workspace/components/dialogs/settings/ProgressBar.vue'
import { useAutoReload } from '@/platform/workspace/composables/useAutoReload'
import { useDialogService } from '@/services/dialogService'
import { cn } from '@comfyorg/tailwind-utils'
const { t, d, n: fmtNumber } = useI18n()
const {
config,
cycleResetDate,
isConfigured,
isEnabled,
hasBudget,
budgetLeftCents,
budgetUsedFraction,
reloadsLeft,
isPaused,
isWarning,
setEnabled
} = useAutoReload()
const { showAutoReloadDialog } = useDialogService()
function openConfig() {
void showAutoReloadDialog()
}
const fmtCredits = (value: number) =>
fmtNumber(value, { maximumFractionDigits: 0 })
const fmtUsd = (cents: number) =>
(cents / 100).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
})
const enabledLabel = computed(() =>
isEnabled.value
? t('workspacePanel.autoReload.enabled')
: t('workspacePanel.autoReload.disabled')
)
const badge = computed(() => {
if (isPaused.value) return t('workspacePanel.autoReload.badge.paused')
if (!isEnabled.value) return t('workspacePanel.autoReload.badge.off')
return ''
})
const reloadCreditsLabel = computed(() => fmtCredits(config.reloadCredits))
const thresholdLabel = computed(() => fmtCredits(config.thresholdCredits))
const resetsLabel = computed(() =>
t('workspacePanel.autoReload.tile.resets', {
date: d(cycleResetDate, { month: 'short', day: 'numeric' })
})
)
const spentLabel = computed(() =>
t('workspacePanel.autoReload.tile.spent', {
amount: fmtUsd(config.spentThisCycleCents)
})
)
const leftLabel = computed(() =>
t('workspacePanel.autoReload.tile.leftOfBudget', {
left: fmtUsd(budgetLeftCents.value),
budget: fmtUsd(config.monthlyBudgetCents ?? 0)
})
)
const reloadsLeftLabel = computed(() =>
t('workspacePanel.autoReload.tile.reloadsLeft', reloadsLeft.value ?? 0)
)
const reloadsLeftClass = computed(() =>
isPaused.value
? 'text-danger'
: isWarning.value
? 'text-credit'
: 'text-muted-foreground'
)
const recentReloadLabel = computed(() =>
config.lastReload
? t('workspacePanel.autoReload.tile.recentReload', {
date: d(config.lastReload.date, {
month: 'short',
day: 'numeric',
year: 'numeric'
})
})
: t('workspacePanel.autoReload.tile.noRecentReload')
)
const lastReloadCreditsLabel = computed(() =>
config.lastReload ? fmtCredits(config.lastReload.credits) : ''
)
const lastReloadCostLabel = computed(() =>
config.lastReload ? fmtUsd(creditsToCents(config.lastReload.credits)) : ''
)
</script>

View File

@@ -1,74 +0,0 @@
<template>
<div
v-if="banner"
class="flex items-center gap-2 rounded-2xl border border-interface-stroke/60 bg-base-background p-4"
>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex items-center gap-2">
<i
:class="
cn(
'icon-[lucide--circle-alert] size-4 shrink-0',
banner.kind === 'warning'
? 'text-warning-background'
: 'text-muted-foreground'
)
"
/>
<span class="text-sm text-base-foreground">{{ banner.title }}</span>
</div>
<p class="m-0 pl-6 text-sm text-muted-foreground">{{ banner.body }}</p>
</div>
<Button v-if="banner.showAction" variant="inverted" size="lg">
{{ $t('workspacePanel.billingStatus.updatePayment') }}
</Button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { cn } from '@comfyorg/tailwind-utils'
const { t, d } = useI18n()
const { billingStatus, subscriptionStatus, renewalDate } = useBillingContext()
const { permissions, isInPersonalWorkspace } = useWorkspaceUI()
const canManage = computed(() => permissions.value.canManageSubscription)
// A payment failure surfaces one banner across every workspace tab. Paused takes
// priority over the grace-period warning; the warning is owner/admin-only since
// members can't act on it (they see nothing until it actually pauses).
const banner = computed(() => {
if (isInPersonalWorkspace.value) return null
if (subscriptionStatus.value === 'paused') {
return {
kind: 'paused' as const,
title: t('workspacePanel.billingStatus.paused.title'),
body: canManage.value
? t('workspacePanel.billingStatus.paused.body')
: t('workspacePanel.billingStatus.paused.memberBody'),
showAction: canManage.value
}
}
if (billingStatus.value === 'payment_failed' && canManage.value) {
const raw = renewalDate.value
return {
kind: 'warning' as const,
title: t('workspacePanel.billingStatus.warning.title'),
body: t('workspacePanel.billingStatus.warning.body', {
date: raw ? d(new Date(raw), { month: 'short', day: 'numeric' }) : ''
}),
showAction: true
}
}
return null
})
</script>

View File

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

View File

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

View File

@@ -1,41 +0,0 @@
<template>
<div
class="flex items-center gap-2 rounded-2xl border border-border-subtle bg-base-background p-3"
>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div class="flex items-center gap-2">
<i
class="icon-[lucide--triangle-alert] size-4 shrink-0 text-warning-background"
/>
<span class="text-sm text-base-foreground">
{{ $t('workspacePanel.members.outOfCredits.title') }}
</span>
</div>
<p class="m-0 pl-6 text-sm text-muted-foreground">
{{
resetDate
? $t('workspacePanel.members.outOfCredits.bodyWithReset', {
date: resetDate
})
: $t('workspacePanel.members.outOfCredits.body')
}}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<Button variant="textonly" size="lg" @click="$emit('dismiss')">
{{ $t('workspacePanel.members.outOfCredits.dismiss') }}
</Button>
<Button variant="secondary" size="lg" @click="$emit('add-credits')">
{{ $t('workspacePanel.members.outOfCredits.addCredits') }}
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
defineProps<{ resetDate?: string | null }>()
defineEmits<{ dismiss: []; 'add-credits': [] }>()
</script>

View File

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

View File

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

View File

@@ -1,25 +0,0 @@
<template>
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full bg-secondary-background-hover"
>
<i :class="cn(getProviderIcon(partner), 'size-3')" :style="iconStyle" />
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { getProviderBorderStyle, getProviderIcon } from '@/utils/categoryUtil'
import { cn } from '@comfyorg/tailwind-utils'
const { partner } = defineProps<{ partner: string }>()
// Monotone provider glyphs (Anthropic, BFL, …) render in currentColor, so tint
// them with the brand color. Multi-color brands (ByteDance, Kling, Gemini, …)
// ship full-color icons — identified by a gradient brand color — and must be
// left untouched or the tint replaces their artwork.
const iconStyle = computed(() => {
const style = getProviderBorderStyle(partner)
return style.includes('gradient') ? undefined : { color: style }
})
</script>

View File

@@ -1,241 +0,0 @@
<template>
<div class="relative flex min-h-0 flex-1 flex-col gap-4">
<div class="flex w-full items-center gap-9">
<span class="min-w-0 flex-1 text-sm text-muted-foreground">
{{ $t('workspacePanel.partnerNodes.description') }}
</span>
<SearchInput
v-model="searchQuery"
:placeholder="$t('workspacePanel.partnerNodes.searchPlaceholder')"
size="lg"
class="w-64"
/>
</div>
<BillingStatusBanner />
<div
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
>
<Table class="min-h-0 flex-1 scrollbar-gutter-stable px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead class="w-6">
<Checkbox
:model-value="allFilteredSelected"
:aria-label="$t('workspacePanel.partnerNodes.selectAll')"
@update:model-value="toggleSelectAll"
/>
</TableHead>
<TableHead>
<button :class="sortHeaderClass" @click="toggleSort('name')">
{{ $t('workspacePanel.partnerNodes.columns.name') }}
<i :class="sortIcon('name')" />
</button>
</TableHead>
<TableHead class="w-40">
<button :class="sortHeaderClass" @click="toggleSort('partner')">
{{ $t('workspacePanel.partnerNodes.columns.partner') }}
<i :class="sortIcon('partner')" />
</button>
</TableHead>
<TableHead class="w-40">
<button
:class="sortHeaderClass"
@click="toggleSort('lastModified')"
>
{{ $t('workspacePanel.partnerNodes.columns.lastModified') }}
<i :class="sortIcon('lastModified')" />
</button>
</TableHead>
<TableHead class="w-14" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="node in filteredNodes"
:key="node.id"
:data-state="selectedIds.has(node.id) ? 'selected' : undefined"
class="group cursor-pointer hover:bg-transparent data-[state=selected]:bg-transparent [&:hover>td]:bg-secondary-background/50 [&>td]:transition-colors [&>td:first-child]:rounded-l [&>td:last-child]:rounded-r [&[data-state=selected]>td]:bg-secondary-background/50"
@click="toggleSelection(node.id)"
>
<TableCell>
<Checkbox
:model-value="selectedIds.has(node.id)"
:aria-label="node.name"
:class="
cn(
'pointer-events-none',
!hasSelection &&
'opacity-0 transition-opacity group-hover:opacity-100'
)
"
/>
</TableCell>
<TableCell class="text-muted-foreground">
<span :class="cn(!node.enabled && 'opacity-30')">
{{ node.name }}
</span>
</TableCell>
<TableCell class="text-muted-foreground">
<div
:class="
cn('flex items-center gap-2', !node.enabled && 'opacity-30')
"
>
<PartnerBadge :partner="node.partner" />
<span>{{ node.partner }}</span>
</div>
</TableCell>
<TableCell class="text-muted-foreground">
{{ formatLastModified(node.last_modified) }}
</TableCell>
<TableCell class="text-right" @click.stop>
<Switch
:model-value="node.enabled"
@update:model-value="(v: boolean) => setEnabled(node, v)"
/>
</TableCell>
</TableRow>
<TableRow
v-if="filteredNodes.length === 0"
class="hover:bg-transparent"
>
<TableCell
:colspan="5"
class="py-6 text-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.partnerNodes.empty') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Auto-enable default: outside the card, pinned to the panel bottom. pr-6
lines the toggle up with the in-table row toggles (table px-4 + cell px-2);
the reserved scrollbar gutter mirrors the table's so the two stay aligned
whether or not the list is scrolling. -->
<div
class="flex h-8 scrollbar-gutter-stable items-center justify-end gap-2 overflow-y-auto pr-6 text-sm text-muted-foreground"
>
<span>{{ $t('workspacePanel.partnerNodes.autoEnableLabel') }}</span>
<!-- Both strings occupy the same grid cell so its width is fixed to the
longer one; only the active label is visible, so the row never reflows. -->
<span class="grid justify-items-end text-base-foreground">
<span
:class="cn('col-start-1 row-start-1', !autoEnableNew && 'invisible')"
>
{{ $t('workspacePanel.partnerNodes.autoEnabled') }}
</span>
<span
:class="cn('col-start-1 row-start-1', autoEnableNew && 'invisible')"
>
{{ $t('workspacePanel.partnerNodes.autoDisabled') }}
</span>
</span>
<Switch
:model-value="autoEnableNew"
@update:model-value="setAutoEnableNew"
/>
</div>
<!-- Bulk selection toolbar: overlaid so toggling it doesn't reflow the panel -->
<div class="absolute inset-x-0 bottom-0">
<Transition
enter-active-class="transition-opacity duration-150"
leave-active-class="transition-opacity duration-150"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<SelectionBar
v-if="selectedCount > 0"
:label="
$t('workspacePanel.partnerNodes.selectedCount', selectedCount)
"
:deselect-label="$t('workspacePanel.partnerNodes.clearSelection')"
@deselect="clearSelection"
>
<Switch :model-value="bulkEnabled" @update:model-value="applyBulk" />
</SelectionBar>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import SelectionBar from '@/components/common/SelectionBar.vue'
import Checkbox from '@/components/ui/checkbox/Checkbox.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Switch from '@/components/ui/switch/Switch.vue'
import Table from '@/components/ui/table/Table.vue'
import TableBody from '@/components/ui/table/TableBody.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableHead from '@/components/ui/table/TableHead.vue'
import TableHeader from '@/components/ui/table/TableHeader.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
import PartnerBadge from '@/platform/workspace/components/dialogs/settings/PartnerBadge.vue'
import { usePartnerNodes } from '@/platform/workspace/composables/usePartnerNodes'
import { cn } from '@comfyorg/tailwind-utils'
const { t } = useI18n()
const {
autoEnableNew,
searchQuery,
sortField,
sortDirection,
selectedIds,
selectedCount,
allFilteredSelected,
filteredNodes,
fetch,
toggleSort,
setEnabled,
setSelectedEnabled,
setAutoEnableNew,
toggleSelection,
toggleSelectAll,
clearSelection
} = usePartnerNodes()
const hasSelection = computed(() => selectedCount.value > 0)
const sortHeaderClass =
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
function sortIcon(field: 'name' | 'partner' | 'lastModified') {
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
return sortDirection.value === 'asc'
? 'icon-[lucide--chevron-up] size-3'
: 'icon-[lucide--chevron-down] size-3'
}
// When every selected node is enabled the bulk switch reads "on", so a toggle
// disables the whole selection; otherwise it enables them.
const bulkEnabled = computed(() =>
filteredNodes.value
.filter((n) => selectedIds.value.has(n.id))
.every((n) => n.enabled)
)
function applyBulk(value: boolean) {
void setSelectedEnabled(value)
}
function formatLastModified(iso: string | null): string {
if (!iso) return t('workspacePanel.partnerNodes.neverModified')
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
onMounted(fetch)
</script>

View File

@@ -1,88 +0,0 @@
<template>
<TableRow
:data-testid="`invite-row-${invite.id}`"
class="group hover:bg-transparent"
>
<TableCell>
<div class="flex items-center gap-3">
<span
class="flex size-8 shrink-0 items-center justify-center rounded-full"
:style="{ backgroundColor: userBadgeColor(invite.email) }"
>
<span class="text-sm font-bold text-base-foreground">
{{ inviteInitial }}
</span>
</span>
<span class="min-w-0 flex-1 truncate text-sm text-base-foreground">
{{ invite.email }}
</span>
</div>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{{ formatDate(invite.inviteDate) }}
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{{ formatDate(invite.expiryDate) }}
</TableCell>
<TableCell v-if="canManage" class="text-right" @click.stop>
<DropdownMenu
:entries="menuItems"
:modal="false"
content-class="min-w-44"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
size="icon"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
</Button>
</template>
</DropdownMenu>
</TableCell>
</TableRow>
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
const { invite, canManage } = defineProps<{
invite: PendingInvite
canManage: boolean
}>()
const emit = defineEmits<{
resend: [invite: PendingInvite]
revoke: [invite: PendingInvite]
}>()
const { t, d } = useI18n()
const inviteInitial = computed(() => invite.email.charAt(0).toUpperCase())
const menuItems = computed<MenuItem[]>(() => [
{
label: t('workspacePanel.members.actions.resendInvite'),
command: () => emit('resend', invite)
},
{
label: t('workspacePanel.members.actions.cancelInvite'),
command: () => emit('revoke', invite)
}
])
function formatDate(date: Date): string {
return d(date, { dateStyle: 'medium' })
}
</script>

View File

@@ -0,0 +1,85 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Slots } from 'vue'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import PendingInvitesList from './PendingInvitesList.vue'
import type { PendingInvite } from '../../../stores/teamWorkspaceStore'
const mockMenuClose = vi.hoisted(() => vi.fn())
vi.mock('@/components/button/MoreButton.vue', () => ({
default: (_: unknown, { slots }: { slots: Slots }) =>
h('div', slots.default?.({ close: mockMenuClose }))
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function createInvite(overrides: Partial<PendingInvite> = {}): PendingInvite {
return {
id: 'invite-1',
email: 'invitee@example.com',
inviteDate: new Date('2025-03-01'),
expiryDate: new Date('2025-04-01'),
...overrides
}
}
function renderComponent(invites: PendingInvite[]) {
return render(PendingInvitesList, {
props: {
invites,
gridCols: 'grid-cols-[50%_20%_20%_10%]'
},
global: { plugins: [i18n] }
})
}
describe('PendingInvitesList', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows the empty state without action buttons when there are no invites', () => {
renderComponent([])
expect(screen.getByText('workspacePanel.members.noInvites')).toBeTruthy()
expect(screen.queryAllByRole('button')).toHaveLength(0)
})
it('emits resend with the invite and closes the menu', async () => {
const invite = createInvite({ id: 'inv-7' })
const { emitted } = renderComponent([invite])
await userEvent.click(
screen.getByRole('button', {
name: 'workspacePanel.members.actions.resendInvite'
})
)
expect(emitted('resend')).toEqual([[invite]])
expect(mockMenuClose).toHaveBeenCalled()
})
it('emits revoke with the invite from the cancel item', async () => {
const invite = createInvite({ id: 'inv-8' })
const { emitted } = renderComponent([invite])
await userEvent.click(
screen.getByRole('button', {
name: 'workspacePanel.members.actions.cancelInvite'
})
)
expect(emitted('revoke')).toEqual([[invite]])
})
})

View File

@@ -0,0 +1,112 @@
<template>
<div>
<div
v-for="(invite, index) in invites"
:key="invite.id"
:class="
cn(
'grid w-full items-center rounded-lg p-2',
gridCols,
index % 2 === 1 && 'bg-secondary-background/50'
)
"
>
<div class="flex items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-full bg-secondary-background"
>
<span class="text-sm font-bold text-base-foreground">
{{ getInviteInitial(invite.email) }}
</span>
</div>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ getInviteDisplayName(invite.email) }}
</span>
<span class="text-sm text-muted-foreground">
{{ invite.email }}
</span>
</div>
</div>
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.inviteDate) }}
</span>
<span class="text-sm text-muted-foreground">
{{ formatDate(invite.expiryDate) }}
</span>
<div class="flex items-center justify-end">
<MoreButton v-slot="{ close }" :aria-label="$t('g.moreOptions')">
<Button
variant="textonly"
size="unset"
:class="menuItemClass"
@click="
() => {
close()
$emit('resend', invite)
}
"
>
<i class="icon-[lucide--mail-plus] size-4" />
<span>{{ $t('workspacePanel.members.actions.resendInvite') }}</span>
</Button>
<Button
variant="textonly"
size="unset"
:class="menuItemClass"
@click="
() => {
close()
$emit('revoke', invite)
}
"
>
<i class="icon-[lucide--mail-x] size-4" />
<span>{{ $t('workspacePanel.members.actions.cancelInvite') }}</span>
</Button>
</MoreButton>
</div>
</div>
<div
v-if="invites.length === 0"
class="flex w-full items-center justify-center py-8 text-sm text-muted-foreground"
>
{{ $t('workspacePanel.members.noInvites') }}
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import type { PendingInvite } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const menuItemClass = 'w-full justify-start rounded-sm px-3 py-2'
defineProps<{
invites: PendingInvite[]
gridCols: string
}>()
defineEmits<{
resend: [invite: PendingInvite]
revoke: [invite: PendingInvite]
}>()
const { d } = useI18n()
function getInviteDisplayName(email: string): string {
return email.split('@')[0]
}
function getInviteInitial(email: string): string {
return email.charAt(0).toUpperCase()
}
function formatDate(date: Date): string {
return d(date, { dateStyle: 'medium' })
}
</script>

View File

@@ -1,87 +0,0 @@
<template>
<div
:class="
cn(
'flex min-h-0 flex-1 flex-col gap-4',
// The panel runs flush to the bottom edge (BaseModalLayout 'flush'); the
// Activity/Invoices tables keep their prior bottom gap, Overview doesn't.
activeView !== 'overview' && 'pb-10'
)
"
>
<div class="flex w-full items-center gap-9">
<div class="flex min-w-0 flex-1 items-center gap-2">
<Button
v-for="tab in tabs"
:key="tab.key"
:variant="activeView === tab.key ? 'secondary' : 'muted-textonly'"
size="lg"
@click="setView(tab.key)"
>
{{ tab.label }}
</Button>
</div>
<SearchInput
v-if="activeView !== 'overview'"
v-model="searchQuery"
:placeholder="$t('g.search')"
size="lg"
class="w-64"
/>
</div>
<BillingStatusBanner />
<WorkspaceOverviewContent
v-if="activeView === 'overview'"
@navigate="setView"
/>
<WorkspaceActivityContent
v-else-if="activeView === 'activity'"
:search="searchQuery"
/>
<WorkspaceInvoicesContent v-else :search="searchQuery" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
import WorkspaceActivityContent from '@/platform/workspace/components/dialogs/settings/WorkspaceActivityContent.vue'
import WorkspaceOverviewContent from '@/platform/workspace/components/dialogs/settings/WorkspaceOverviewContent.vue'
import WorkspaceInvoicesContent from '@/platform/workspace/components/dialogs/settings/WorkspaceInvoicesContent.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { cn } from '@comfyorg/tailwind-utils'
type View = 'overview' | 'activity' | 'invoices'
const { t } = useI18n()
const { permissions } = useWorkspaceUI()
const tabs = computed(() => {
const base: { key: View; label: string }[] = [
{ key: 'overview', label: t('workspacePanel.planCredits.tabs.overview') },
{ key: 'activity', label: t('workspacePanel.planCredits.tabs.activity') }
]
// Invoices are billing details — owners/admins only.
if (permissions.value.canManageSubscription) {
base.push({
key: 'invoices',
label: t('workspacePanel.planCredits.tabs.invoices')
})
}
return base
})
const activeView = ref<View>('overview')
const searchQuery = ref('')
function setView(view: View) {
activeView.value = view
searchQuery.value = ''
}
</script>

View File

@@ -1,18 +0,0 @@
<template>
<div
class="h-2 w-full overflow-hidden rounded-full bg-secondary-background-hover"
>
<div
class="h-full rounded-full bg-credit transition-[width]"
:style="{ width: `${clamped * 100}%` }"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const { value } = defineProps<{ value: number }>()
const clamped = computed(() => Math.min(1, Math.max(0, value)))
</script>

View File

@@ -1,286 +0,0 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<div
ref="tableContainer"
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
>
<Table class="min-h-0 flex-1 px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead class="w-40">
<button :class="sortHeaderClass" @click="toggleSort('date')">
{{ $t('workspacePanel.activity.columns.date') }}
<i :class="sortIcon('date')" />
</button>
</TableHead>
<TableHead>
<button :class="sortHeaderClass" @click="toggleSort('user')">
{{ $t('workspacePanel.activity.columns.user') }}
<i :class="sortIcon('user')" />
</button>
</TableHead>
<TableHead class="w-48">
<button :class="sortHeaderClass" @click="toggleSort('eventType')">
{{ $t('workspacePanel.activity.columns.eventType') }}
<i :class="sortIcon('eventType')" />
</button>
</TableHead>
<TableHead class="w-32">
<button :class="sortHeaderClass" @click="toggleSort('detail')">
{{ $t('workspacePanel.activity.columns.eventDetails') }}
<i :class="sortIcon('detail')" />
</button>
</TableHead>
<TableHead class="w-40">
<button
:class="cn(sortHeaderClass, 'ml-auto')"
@click="toggleSort('credits')"
>
<i class="icon-[lucide--coins] size-4" />
{{ $t('workspacePanel.activity.columns.creditsUsed') }}
<i :class="sortIcon('credits')" />
</button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="event in pagedItems"
:key="event.id"
class="hover:bg-transparent [&:nth-child(even)>td]:bg-secondary-background/25 [&>td:first-child]:rounded-l [&>td:last-child]:rounded-r"
>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ formatDate(event.date) }}
</TableCell>
<TableCell>
<HoverCard :open-delay="150" :close-delay="0">
<HoverCardTrigger
as="div"
class="flex w-fit cursor-default items-center gap-3"
>
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full"
:style="{ backgroundColor: userBadgeColor(event.userName) }"
>
<span class="text-2xs font-bold text-base-foreground">
{{ event.userName.charAt(0).toUpperCase() }}
</span>
</span>
<span class="truncate text-sm text-base-foreground">
{{ event.userName }}
</span>
</HoverCardTrigger>
<HoverCardContent class="w-64">
<div class="flex w-full flex-col gap-2">
<div class="flex h-5 items-center justify-between">
<span class="text-sm text-muted-foreground">
{{
$t(
'workspacePanel.activity.hoverCard.totalCreditsUsed'
)
}}
</span>
<span class="flex items-center gap-1">
<i
class="icon-[lucide--coins] size-4 text-muted-foreground"
/>
<span class="text-sm text-base-foreground tabular-nums">
{{
summaryFor(
event.userName
).totalCredits.toLocaleString()
}}
</span>
</span>
</div>
<div class="flex h-5 items-center justify-between">
<span class="text-sm text-muted-foreground">
{{
$t('workspacePanel.activity.hoverCard.lastActivity')
}}
</span>
<span class="text-sm text-base-foreground">
{{ lastActivityLabel(event.userName) }}
</span>
</div>
</div>
</HoverCardContent>
</HoverCard>
</TableCell>
<TableCell class="text-sm text-muted-foreground">
<HoverCard
v-if="event.partnerNode"
:open-delay="150"
:close-delay="0"
>
<HoverCardTrigger as="span" class="cursor-default">
{{ event.eventType }}
</HoverCardTrigger>
<HoverCardContent class="w-72">
<div class="flex h-5 items-center justify-between gap-4">
<span
class="text-sm whitespace-nowrap text-muted-foreground"
>
{{
$t('workspacePanel.activity.hoverCard.partnerNodeUsed')
}}
</span>
<span class="truncate text-sm text-base-foreground">
{{ event.partnerNode }}
</span>
</div>
</HoverCardContent>
</HoverCard>
<template v-else>{{ event.eventType }}</template>
</TableCell>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ event.detail }}
</TableCell>
<TableCell
class="text-right text-sm text-muted-foreground tabular-nums"
>
{{ event.credits.toLocaleString() }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div class="flex h-8 items-center">
<div v-if="canViewTeamUsage" class="flex items-center gap-6">
<a
:href="fullActivityUrl"
target="_blank"
rel="noopener noreferrer"
class="flex cursor-pointer items-center gap-1 text-sm text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
<i class="icon-[lucide--external-link] size-4" />
{{ $t('workspacePanel.activity.fullActivity') }}
</a>
<div class="flex items-center gap-3">
<p class="text-sm text-muted-foreground">
{{ $t('workspacePanel.activity.perUserHint') }}
</p>
<button
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-[inherit] text-sm text-base-foreground transition-colors hover:text-muted-foreground"
@click="goToMembers"
>
{{ $t('workspacePanel.activity.seeMembers') }}
<i class="icon-[lucide--arrow-right] size-4" />
</button>
</div>
</div>
<Pagination
v-model:page="page"
:total="total"
:items-per-page="itemsPerPage"
class="ml-auto"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import HoverCard from '@/components/ui/hover-card/HoverCard.vue'
import HoverCardContent from '@/components/ui/hover-card/HoverCardContent.vue'
import HoverCardTrigger from '@/components/ui/hover-card/HoverCardTrigger.vue'
import Pagination from '@/components/ui/pagination/Pagination.vue'
import Table from '@/components/ui/table/Table.vue'
import TableBody from '@/components/ui/table/TableBody.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableHead from '@/components/ui/table/TableHead.vue'
import TableHeader from '@/components/ui/table/TableHeader.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
import { useWorkspaceActivity } from '@/platform/workspace/composables/useWorkspaceActivity'
import type { ActivitySortField } from '@/platform/workspace/composables/useWorkspaceActivity'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { userBadgeColor } from '@/platform/workspace/utils/badgeColor'
import { formatRelativeTime } from '@/platform/workspace/utils/relativeTime'
import { cn } from '@comfyorg/tailwind-utils'
const { search } = defineProps<{ search: string }>()
const { t, d } = useI18n()
const tableContainer = ref<HTMLElement | null>(null)
const { pageSize } = useAutoPageSize(tableContainer)
// Owners/admins see team-wide activity + the per-user footer; members see only
// their own usage, scoped to their name.
const { permissions } = useWorkspaceUI()
const { userDisplayName, userEmail } = useCurrentUser()
const canViewTeamUsage = computed(() => permissions.value.canManageSubscription)
const selfName = computed(() =>
canViewTeamUsage.value
? null
: userDisplayName.value || userEmail.value || t('g.you')
)
const fullActivityUrl = `${getComfyPlatformBaseUrl()}/profile/usage`
const { navigateToPanel } = useSettingsNavigation()
function goToMembers() {
requestMembersSort('credits')
navigateToPanel('workspace-members')
}
const {
page,
total,
itemsPerPage,
pagedItems,
sortField,
sortDirection,
toggleSort,
userSummaries
} = useWorkspaceActivity(() => search, pageSize, selfName)
function summaryFor(userName: string) {
return (
userSummaries.value.get(userName) ?? {
totalCredits: 0,
lastActivity: new Date()
}
)
}
function lastActivityLabel(userName: string): string {
return formatRelativeTime(summaryFor(userName).lastActivity, new Date(), {
justNow: t('workspacePanel.members.activity.justNow'),
minutesAgo: (n) => t('workspacePanel.members.activity.minutesAgo', { n }),
hoursAgo: (n) => t('workspacePanel.members.activity.hoursAgo', { n }),
daysAgo: (n) => t('workspacePanel.members.activity.daysAgo', n)
})
}
const sortHeaderClass =
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
function sortIcon(field: ActivitySortField) {
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
return sortDirection.value === 'asc'
? 'icon-[lucide--chevron-up] size-3'
: 'icon-[lucide--chevron-down] size-3'
}
function formatDate(date: Date): string {
return d(date, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
})
}
</script>

View File

@@ -1,139 +0,0 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<div
ref="tableContainer"
class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl border border-interface-stroke/60"
>
<Table class="min-h-0 flex-1 px-4">
<TableHeader class="sticky top-0 z-10 bg-base-background">
<TableRow
class="hover:bg-transparent [&>th]:h-14 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead class="w-40">
<button :class="sortHeaderClass" @click="toggleSort('date')">
{{ $t('workspacePanel.invoices.columns.date') }}
<i :class="sortIcon('date')" />
</button>
</TableHead>
<TableHead>
<button :class="sortHeaderClass" @click="toggleSort('eventType')">
{{ $t('workspacePanel.invoices.columns.eventType') }}
<i :class="sortIcon('eventType')" />
</button>
</TableHead>
<TableHead class="w-40">
<button
:class="cn(sortHeaderClass, 'ml-auto')"
@click="toggleSort('price')"
>
{{ $t('workspacePanel.invoices.columns.price') }}
<i :class="sortIcon('price')" />
</button>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="invoice in pagedItems"
:key="invoice.id"
class="hover:bg-transparent [&:nth-child(even)>td]:bg-secondary-background/25 [&>td:first-child]:rounded-l [&>td:last-child]:rounded-r"
>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ formatDate(invoice.date) }}
</TableCell>
<TableCell class="text-sm text-muted-foreground">
{{ invoice.eventType }}
</TableCell>
<TableCell
class="text-right text-sm text-muted-foreground tabular-nums"
>
{{ formatPrice(invoice.amountCents) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div class="flex h-8 items-center justify-between">
<button
class="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 font-[inherit] text-sm text-muted-foreground transition-colors hover:text-base-foreground"
@click="openHistory"
>
<i class="icon-[lucide--external-link] size-4" />
{{ $t('workspacePanel.invoices.fullHistory') }}
</button>
<Pagination
v-model:page="page"
:total="total"
:items-per-page="itemsPerPage"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Pagination from '@/components/ui/pagination/Pagination.vue'
import Table from '@/components/ui/table/Table.vue'
import TableBody from '@/components/ui/table/TableBody.vue'
import TableCell from '@/components/ui/table/TableCell.vue'
import TableHead from '@/components/ui/table/TableHead.vue'
import TableHeader from '@/components/ui/table/TableHeader.vue'
import TableRow from '@/components/ui/table/TableRow.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
import { useWorkspaceInvoices } from '@/platform/workspace/composables/useWorkspaceInvoices'
import type { InvoiceSortField } from '@/platform/workspace/composables/useWorkspaceInvoices'
import { cn } from '@comfyorg/tailwind-utils'
const { search } = defineProps<{ search: string }>()
const { d } = useI18n()
const { manageSubscription } = useBillingContext()
const tableContainer = ref<HTMLElement | null>(null)
const { pageSize } = useAutoPageSize(tableContainer)
const {
page,
total,
itemsPerPage,
pagedItems,
sortField,
sortDirection,
toggleSort
} = useWorkspaceInvoices(() => search, pageSize)
const sortHeaderClass =
'flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left font-[inherit] text-sm text-muted-foreground'
function sortIcon(field: InvoiceSortField) {
if (sortField.value !== field) return 'icon-[lucide--chevrons-up-down] size-3'
return sortDirection.value === 'asc'
? 'icon-[lucide--chevron-up] size-3'
: 'icon-[lucide--chevron-down] size-3'
}
function formatDate(date: Date): string {
return d(date, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
})
}
function formatPrice(cents: number): string {
return (cents / 100).toLocaleString('en-US', {
style: 'currency',
currency: 'USD'
})
}
function openHistory() {
void manageSubscription()
}
</script>

View File

@@ -1,21 +0,0 @@
<template>
<div class="flex size-full flex-col">
<MembersPanelContent :key="workspaceRole" />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
const { workspaceRole } = useWorkspaceUI()
const { fetchMembers, fetchPendingInvites } = useTeamWorkspaceStore()
onMounted(() => {
fetchMembers()
fetchPendingInvites()
})
</script>

View File

@@ -5,7 +5,6 @@ import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
import WorkspaceMenuButton from './WorkspaceMenuButton.vue'
@@ -76,25 +75,6 @@ describe('WorkspaceMenuButton', () => {
mockUiConfig.value = ownerConfig
mockIsCurrentUserOriginalOwner.value = false
mockIsWorkspaceSubscribed.value = false
useWorkspaceRename().stopRenaming()
})
it('offers Rename to an editor and starts inline renaming', async () => {
const user = userEvent.setup()
const { isRenaming } = useWorkspaceRename()
renderComponent()
await user.click(screen.getByRole('button', { name: 'Rename Workspace' }))
expect(isRenaming.value).toBe(true)
})
it('hides Rename from a member', () => {
mockUiConfig.value = memberConfig
renderComponent()
expect(
screen.queryByRole('button', { name: 'Rename Workspace' })
).not.toBeInTheDocument()
})
it('lets a member leave and offers no destructive workspace actions', () => {

View File

@@ -1,16 +1,10 @@
<template>
<DropdownMenu
v-if="menuItems.length > 0"
:entries="menuItems"
:modal="false"
@close-auto-focus="onMenuCloseAutoFocus"
>
<DropdownMenu :entries="menuItems">
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
variant="muted-textonly"
size="icon-lg"
class="rounded-lg"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
@@ -27,35 +21,20 @@ import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { useDialogService } from '@/services/dialogService'
const { t } = useI18n()
const { showLeaveWorkspaceDialog, showDeleteWorkspaceDialog } =
useDialogService()
const {
showLeaveWorkspaceDialog,
showDeleteWorkspaceDialog,
showEditWorkspaceDialog
} = useDialogService()
const { isWorkspaceSubscribed, isCurrentUserOriginalOwner } = storeToRefs(
useTeamWorkspaceStore()
)
const { uiConfig } = useWorkspaceUI()
const { startRenaming } = useWorkspaceRename()
// Reka returns focus to the trigger when the menu closes, which would blur (and
// so tear down) the rename input we're about to focus. Suppress that focus
// restoration for the one close that kicks off a rename.
let renameStarting = false
function beginRename() {
renameStarting = true
startRenaming()
}
function onMenuCloseAutoFocus(event: Event) {
if (!renameStarting) return
renameStarting = false
event.preventDefault()
}
// Disable delete when the workspace has an active subscription (prevents
// accidental deletion); uses the workspace's own status, not the global one.
@@ -72,21 +51,22 @@ const deleteTooltip = computed(() => {
})
const menuItems = computed<MenuItem[]>(() => {
const renameItems: MenuItem[] = uiConfig.value.showEditWorkspaceMenuItem
? [
{
label: t('workspacePanel.menu.renameWorkspace'),
command: beginRename
}
]
: []
const items: MenuItem[] = []
if (uiConfig.value.showEditWorkspaceMenuItem) {
items.push({
label: t('workspacePanel.menu.editWorkspace'),
icon: 'pi pi-pencil',
command: () => showEditWorkspaceDialog()
})
}
const destructiveItems: MenuItem[] = []
const action = uiConfig.value.workspaceMenuAction
if (action === 'delete') {
destructiveItems.push({
items.push({
label: t('workspacePanel.menu.deleteWorkspace'),
class: isDeleteDisabled.value ? undefined : 'text-danger',
icon: 'pi pi-trash',
class: isDeleteDisabled.value ? 'text-danger/50' : 'text-danger',
disabled: isDeleteDisabled.value,
tooltip: deleteTooltip.value,
command: isDeleteDisabled.value
@@ -97,23 +77,23 @@ const menuItems = computed<MenuItem[]>(() => {
// Members and non-creator owners can leave; the creator sees it disabled.
if (action === 'leave' || action === 'delete') {
destructiveItems.push(
items.push(
isCurrentUserOriginalOwner.value
? {
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',
class: 'opacity-50',
disabled: true,
tooltip: t('workspacePanel.menu.creatorCannotLeave')
}
: {
label: t('workspacePanel.menu.leaveWorkspace'),
icon: 'pi pi-sign-out',
command: () => showLeaveWorkspaceDialog()
}
)
}
const divider: MenuItem[] =
renameItems.length && destructiveItems.length ? [{ separator: true }] : []
return [...renameItems, ...divider, ...destructiveItems]
return items
})
</script>

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