Compare commits

..

6 Commits

Author SHA1 Message Date
comfydesigner
b0c5775f49 feat: workspace activity ledger + member auditing
Adds the Activity tab to Plan & Credits: a sortable, paginated credit
ledger (auto page-sized to the dialog) with per-user hover-card rollups
and partner-node detail on usage rows; members are scoped to their own
rows, owners and admins see everyone. The Credits-tab snapshot's Recent
activity now deep-links here, and the tab-row search scopes the ledger.
There is no usage-activity endpoint yet — the ledger renders its empty
state until events arrive; the filter/sort/pagination/rollup machinery
is real and ready for the data.
2026-07-10 15:11:30 -07:00
comfydesigner
f5c5597d70 feat: credit auto-reload with a monthly budget
Adds the auto-reload section to the Credits tab: threshold + reload
amount configured through a dialog (with a minimum-amount guard), an
optional monthly budget rendered as a color-coded progress bar (muted,
amber near the cap, red and Paused-badged when exhausted), and a
toggle-first enable sentence. The whole section freezes — dimmed,
forced off — whenever the plan can't spend (paused or lapsed), and
card failures are covered by the shared payment-declined banner rather
than tile-level state. Config is client-side until the auto-reload
endpoint exists; it starts unconfigured and edits don't persist.
2026-07-10 15:10:40 -07:00
comfydesigner
85f9a5347d feat: Plan & Credits settings tabs
Replaces the combined Workspace panel with the dedicated Plan & Credits
entry (keeping the 'workspace' key so deep links land) hosting Credits
and Invoices tabs, role-gated: Invoices is billing-managers only. The
Invoices tab surfaces the upcoming charge with a Full invoice history
link to the Stripe portal — invoice history and downloads live in
Stripe by design; the banner stays hidden until the API exposes the
upcoming amount. On the paused state the shared status banner hosts the
history link instead so banners never stack. Sidebar icons: receipt for
Plan & Credits, users for Members.
2026-07-10 15:08:56 -07:00
comfydesigner
f2d632385b feat: billing lifecycle states across workspace settings
One status-banner slot (BillingStatusBanner) above every workspace
settings surface, rendering by priority: subscription paused / payment
declined / out of credits (amber triangle, action needed) and the plan-
ending notice (muted circle, secondary Reactivate — cancellation was
intentional). Adds the paused subscription status, the Credits tab
header with plan-aware inactive copy (team vs enterprise) wired to
resubscribe, and a member snapshot tile (top spenders / recent
activity from the members store, sized to the dialog via
useAutoPageSize; renders its empty states until the API carries
per-member usage).

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

The existing combined Workspace panel stays registered so plan and
credit management remains reachable; a follow-up swaps it to the
dedicated Plan & Credits panel. Table primitives (nowrap cells,
divider rows) land here with their first consumer.
2026-07-10 15:04:56 -07:00
comfydesigner
a898e39d20 refactor: extract shared SelectionBar + dialog/menu plumbing
Extracts the floating selection toolbar out of MediaAssetSelectionBar
into a reusable SelectionBar (label + deselect + action slot), gives
DropdownMenu an opt-out modal prop, widens the reka<->PrimeVue dismiss
bridge to popup triggers so closing a menu doesn't dismiss the host
dialog, and bumps SearchInput text to 14px. Groundwork for the
workspace settings panels.
2026-07-10 15:04:09 -07:00
125 changed files with 4595 additions and 4090 deletions

View File

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

View File

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

View File

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

View File

@@ -426,7 +426,7 @@ describe('shouldPreventRekaDismiss', () => {
expect(event.defaultPrevented).toBe(true)
})
it.for(['p-dialog', 'p-select-overlay', 'p-toast'])(
it.for(['p-dialog', 'p-select-overlay'])(
'focus-outside on a sibling %s portal does not dismiss the parent',
(className) => {
const overlay = document.createElement('div')

View File

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

View File

@@ -1,13 +1,10 @@
// PrimeVue overlays (Select, ColorPicker, Popover, Autocomplete, stacked
// PrimeVue Dialogs, Toasts) teleport to body. Reka treats clicks on
// body-portaled elements as outside its dialog and would auto-dismiss on the
// first interaction, tearing the overlay down mid-interaction. Treat any
// PrimeVue overlay click as inside. Toasts matter for focus-outside: when a
// button disables itself mid-action (e.g. a confirm entering its loading
// state), the browser drops focus and recovery can land on the toast's close
// button, which must not dismiss the dialog underneath.
// PrimeVue Dialogs) teleport to body. Reka treats clicks on body-portaled
// elements as outside its dialog and would auto-dismiss on the first
// interaction, tearing the overlay down mid-interaction. Treat any
// PrimeVue overlay click as inside.
const PRIMEVUE_OVERLAY_SELECTORS =
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog, .p-toast'
'.p-select-overlay, .p-colorpicker-panel, .p-popover, .p-autocomplete-overlay, .p-overlay, .p-overlay-mask, .p-dialog'
// 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
@@ -15,7 +12,7 @@ const PRIMEVUE_OVERLAY_SELECTORS =
// 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"]'
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"], [aria-haspopup="menu"], [aria-haspopup="dialog"], [aria-haspopup="listbox"]'
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
@@ -19,10 +18,7 @@ const {
mockGetNodeProvider,
mockToggleNodeOnEvent,
mockRefreshModelFolder,
mockLoadModels,
downloadStoreState,
settingState,
modelsState
downloadStoreState
} = vi.hoisted(() => {
let capturedRoot: TreeExplorerNode<unknown> | null = null
return {
@@ -37,13 +33,7 @@ const {
mockGetNodeProvider: vi.fn(),
mockToggleNodeOnEvent: vi.fn(),
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
mockLoadModels: vi.fn().mockResolvedValue([]),
downloadStoreState: { setLastCompleted: (_: unknown) => {} },
settingState: { useAssetAPI: false, autoLoadAll: false },
modelsState: {
push: (_: unknown) => {},
reset: () => {}
}
downloadStoreState: { setLastCompleted: (_: unknown) => {} }
}
})
@@ -64,30 +54,20 @@ const mockModel = fromPartial<ComfyModelDef>({
searchable: 'checkpoints/model.safetensors'
})
vi.mock('@/stores/modelStore', async () => {
const { reactive } = await import('vue')
const models = reactive<ComfyModelDef[]>([])
modelsState.push = (model: unknown) => {
models.push(model as ComfyModelDef)
}
modelsState.reset = () => {
models.splice(0, models.length, mockModel)
}
return {
ResourceState: {
Loading: 'loading',
Loaded: 'loaded'
},
useModelStore: () => ({
modelFolders: [],
models,
loadModels: mockLoadModels,
loadModelFolders: vi.fn().mockResolvedValue([]),
refresh: vi.fn().mockResolvedValue(undefined),
refreshModelFolder: mockRefreshModelFolder
})
}
})
vi.mock('@/stores/modelStore', () => ({
ResourceState: {
Loading: 'loading',
Loaded: 'loaded'
},
useModelStore: () => ({
modelFolders: [],
models: [mockModel],
loadModels: vi.fn().mockResolvedValue([]),
loadModelFolders: vi.fn().mockResolvedValue([]),
refresh: vi.fn().mockResolvedValue(undefined),
refreshModelFolder: mockRefreshModelFolder
})
}))
vi.mock('@/stores/assetDownloadStore', async () => {
const { ref } = await import('vue')
@@ -112,10 +92,6 @@ vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn((key: string) => {
if (key === 'Comfy.ModelLibrary.NameFormat') return 'filename'
if (key === 'Comfy.Assets.UseAssetAPI') return settingState.useAssetAPI
if (key === 'Comfy.ModelLibrary.AutoLoadAll') {
return settingState.autoLoadAll
}
return false
})
})
@@ -128,45 +104,26 @@ vi.mock('@/composables/useTreeExpansion', () => ({
})
}))
vi.mock('@/components/common/TreeExplorer.vue', async () => {
const { watchEffect } = await import('vue')
return {
default: {
name: 'TreeExplorer',
template: '<div data-testid="tree-explorer" />',
props: ['root', 'expandedKeys'],
setup(props: { root: TreeExplorerNode<unknown> }) {
watchEffect(() => captureRoot(props.root))
}
vi.mock('@/components/common/TreeExplorer.vue', () => ({
default: {
name: 'TreeExplorer',
template: '<div data-testid="tree-explorer" />',
props: ['root', 'expandedKeys'],
setup(props: { root: TreeExplorerNode<unknown> }) {
captureRoot(props.root)
}
}
})
}))
vi.mock('@/components/ui/search-input/SearchInput.vue', () => ({
default: {
name: 'SearchInput',
template: '<input data-testid="search-input" @input="onInput" />',
template: '<input data-testid="search-input" />',
props: ['modelValue', 'placeholder'],
emits: ['update:modelValue', 'search'],
setup(
_props: unknown,
{
emit,
expose
}: {
emit: (event: 'update:modelValue' | 'search', value: string) => void
expose: (exposed: Record<string, unknown>) => void
}
) {
expose({ focus: vi.fn() })
return {
onInput: (event: Event) => {
const value = (event.target as HTMLInputElement).value
emit('update:modelValue', value)
emit('search', value)
}
}
}
setup() {
return { focus: vi.fn() }
},
expose: ['focus']
}
}))
@@ -177,8 +134,7 @@ vi.mock('./SidebarTopArea.vue', () => ({
vi.mock('./SidebarTabTemplate.vue', () => ({
default: {
name: 'SidebarTabTemplate',
template:
'<div><slot name="tool-buttons" /><slot name="header" /><slot name="body" /></div>'
template: '<div><slot name="header" /><slot name="body" /></div>'
}
}))
@@ -201,17 +157,13 @@ describe('ModelLibrarySidebarTab', () => {
vi.clearAllMocks()
resetRoot()
downloadStoreState.setLastCompleted(null)
settingState.useAssetAPI = false
settingState.autoLoadAll = false
modelsState.reset()
})
function renderComponent() {
return render(ModelLibrarySidebarTab, {
global: {
plugins: [createTestingPinia({ stubActions: false }), i18n],
stubs: { teleport: true },
directives: { tooltip: {} }
stubs: { teleport: true }
}
})
}
@@ -284,67 +236,4 @@ describe('ModelLibrarySidebarTab', () => {
expect(mockRefreshModelFolder).not.toHaveBeenCalled()
})
describe('search', () => {
it('updates active search results when a reload adds a matching model', async () => {
const user = userEvent.setup()
renderComponent()
await nextTick()
await user.type(screen.getByTestId('search-input'), 'model')
await nextTick()
expect(mockLoadModels).toHaveBeenCalled()
const leafLabels = () => {
const { children: folders = [] } = getRoot()
return folders.flatMap(({ children: leaves = [] }) =>
leaves.map((leaf) => leaf.label)
)
}
expect(leafLabels()).toEqual(['model'])
// A completed scan reloads the store while the search is still active.
modelsState.push(
fromPartial<ComfyModelDef>({
key: 'checkpoints/model-new.safetensors',
file_name: 'model-new.safetensors',
simplified_file_name: 'model-new',
title: 'Model New',
directory: 'checkpoints',
searchable: 'checkpoints/model-new.safetensors'
})
)
await nextTick()
expect(leafLabels()).toEqual(['model', 'model-new'])
})
})
describe('asset mode', () => {
it('hides the load-all button and eager-loads models on mount', async () => {
settingState.useAssetAPI = true
renderComponent()
await nextTick()
expect(screen.queryByLabelText('g.loadAllFolders')).toBeNull()
expect(screen.getByLabelText('g.refresh')).toBeInTheDocument()
expect(mockLoadModels).toHaveBeenCalledTimes(1)
})
it('legacy mode keeps the load-all button and stays lazy by default', async () => {
renderComponent()
await nextTick()
expect(screen.getByLabelText('g.loadAllFolders')).toBeInTheDocument()
expect(mockLoadModels).not.toHaveBeenCalled()
})
it('legacy mode still honors AutoLoadAll', async () => {
settingState.autoLoadAll = true
renderComponent()
await nextTick()
expect(mockLoadModels).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -11,7 +11,6 @@
<i class="icon-[lucide--refresh-cw] size-4" />
</Button>
<Button
v-if="!usesAssetAPI"
v-tooltip.bottom="$t('g.loadAllFolders')"
variant="muted-textonly"
size="icon"
@@ -78,28 +77,28 @@ import { buildTree } from '@/utils/treeUtil'
const modelStore = useModelStore()
const modelToNodeStore = useModelToNodeStore()
const settingStore = useSettingStore()
const usesAssetAPI = computed(() =>
settingStore.get('Comfy.Assets.UseAssetAPI')
)
const assetDownloadStore = useAssetDownloadStore()
const searchBoxRef = ref()
const searchQuery = ref<string>('')
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const filteredModels = computed<ComfyModelDef[]>(() => {
const search = searchQuery.value.toLocaleLowerCase()
if (!search) return []
return modelStore.models.filter((model) => model.searchable.includes(search))
})
const filteredModels = ref<ComfyModelDef[]>([])
const handleSearch = async (query: string) => {
if (!query) {
filteredModels.value = []
expandedKeys.value = {}
return
}
// Load all models to ensure results cover folders not yet opened
// Load all models to ensure we have the latest data
await modelStore.loadModels()
const search = query.toLocaleLowerCase()
filteredModels.value = modelStore.models.filter((model: ComfyModelDef) => {
return model.searchable.includes(search)
})
await nextTick()
expandNode(root.value)
}
type ModelOrFolder = ComfyModelDef | ModelFolder
@@ -113,12 +112,6 @@ const root = computed<TreeNode>(() => {
)
})
watch(root, async (newRoot) => {
if (!searchQuery.value) return
await nextTick()
expandNode(newRoot)
})
const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
const nameFormat = settingStore.get('Comfy.ModelLibrary.NameFormat')
const fillNodeInfo = (node: TreeNode): TreeExplorerNode<ModelOrFolder> => {
@@ -200,13 +193,7 @@ watch(
onMounted(async () => {
searchBoxRef.value?.focus()
// In asset mode the whole library resolves from one cached walk, so eager
// loading is cheap and keeps search and folder badges complete from the
// start; AutoLoadAll remains the opt-in for the request-per-folder legacy path.
if (
usesAssetAPI.value ||
settingStore.get('Comfy.ModelLibrary.AutoLoadAll')
) {
if (settingStore.get('Comfy.ModelLibrary.AutoLoadAll')) {
await modelStore.loadModels()
}
})

View File

@@ -26,7 +26,6 @@ import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { ComfyModelDef } from '@/stores/modelStore'
import { getModelPreviewUrl } from '@/stores/modelStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import ModelPreview from './ModelPreview.vue'
@@ -38,7 +37,17 @@ const props = defineProps<{
// Note: The leaf node should always have a model definition on node.data.
const modelDef = computed<ComfyModelDef>(() => props.node.data!)
const modelPreviewUrl = computed(() => getModelPreviewUrl(modelDef.value))
const modelPreviewUrl = computed(() => {
if (modelDef.value.image) {
return modelDef.value.image
}
const folder = modelDef.value.directory
const path_index = modelDef.value.path_index
const extension = modelDef.value.file_name.split('.').pop()
const filename = modelDef.value.file_name.replace(`.${extension}`, '.webp')
const encodedFilename = encodeURIComponent(filename).replace(/%2F/g, '/')
return `/api/experiment/models/preview/${folder}/${path_index}/${encodedFilename}`
})
const previewRef = ref<InstanceType<typeof ModelPreview> | null>(null)
const modelPreviewStyle = ref<CSSProperties>({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -33,8 +33,7 @@ export enum ServerFeatureFlag {
SHOW_SIGNIN_BUTTON = 'show_signin_button',
UNIFIED_CLOUD_AUTH = 'unified_cloud_auth',
CONSOLIDATED_BILLING_ENABLED = 'consolidated_billing_enabled',
SIGNUP_TURNSTILE = 'signup_turnstile',
SUPPORTS_MODEL_TYPE_TAGS = 'supports_model_type_tags'
SIGNUP_TURNSTILE = 'signup_turnstile'
}
/**
@@ -209,12 +208,6 @@ export function useFeatureFlags() {
remoteConfig.value.signup_turnstile,
'off'
)
},
get supportsModelTypeTags() {
return api.getServerFeature(
ServerFeatureFlag.SUPPORTS_MODEL_TYPE_TAGS,
false
)
}
})

View File

@@ -2593,7 +2593,7 @@
"additionalCreditsInfo": "About additional credits",
"additionalCredits": "Additional credits",
"additionalCreditsInUse": "In use",
"usedAfterMonthly": "Used after monthly runs out",
"usedAfterMonthly": "Used after plan credits run out",
"monthlyCreditsUsedUpTitle": "Monthly credits are used up. Refills {date}",
"monthlyCreditsUsedUpTitleNoDate": "Monthly credits are used up",
"monthlyCreditsUsedUpDescription": "You're now spending additional credits.",
@@ -2831,7 +2831,10 @@
"planUpdated": "Your plan has been successfully updated.",
"receiptEmailed": "A receipt has been emailed to you.",
"sendInvites": "Send invites"
}
},
"enterprisePlanName": "Enterprise",
"percentUsed": "{percent}% used",
"usageProgress": "{used} of {total} credits used"
},
"userSettings": {
"title": "My Account Settings",
@@ -2846,7 +2849,7 @@
"workspacePanel": {
"invite": "Invite",
"inviteMember": "Invite member",
"inviteLimitReached": "You've reached the maximum of {count} members",
"inviteLimitReached": "Your workspace is at the member limit",
"tabs": {
"dashboard": "Dashboard",
"planCredits": "Plan & Credits",
@@ -2861,12 +2864,17 @@
"pendingInvitesCount": "{count} pending invite | {count} pending invites",
"tabs": {
"active": "Active",
"pendingCount": "Pending ({count})"
"pendingCount": "Pending ({count})",
"membersCount": "Members ({count})",
"pending": "Pending"
},
"columns": {
"inviteDate": "Invite date",
"expiryDate": "Expiry date",
"role": "Role"
"role": "Role",
"creditsUsed": "Credits used this month",
"email": "Email",
"lastActivity": "Last activity"
},
"actions": {
"resendInvite": "Resend invite",
@@ -2882,14 +2890,22 @@
"contactUs": "Contact us",
"noInvites": "No pending invites",
"noMembers": "No members",
"searchPlaceholder": "Search..."
"searchPlaceholder": "Search...",
"activity": {
"daysAgo": "{count} day ago | {count} days ago",
"hoursAgo": "{n} hr ago",
"justNow": "just now",
"minutesAgo": "{n} min ago"
},
"membersUsage": "{count} of {max} total members."
},
"menu": {
"editWorkspace": "Edit workspace details",
"leaveWorkspace": "Leave Workspace",
"deleteWorkspace": "Delete Workspace",
"deleteWorkspaceDisabledTooltip": "Cancel your workspace's active subscription first",
"creatorCannotLeave": "The workspace creator can't leave the workspace they created"
"creatorCannotLeave": "The workspace creator can't leave the workspace they created",
"renameWorkspace": "Rename Workspace"
},
"editWorkspaceDialog": {
"title": "Edit workspace details",
@@ -2978,6 +2994,136 @@
"failedToDeleteWorkspace": "Failed to delete workspace",
"failedToLeaveWorkspace": "Failed to leave workspace",
"failedToFetchWorkspaces": "Failed to load workspaces"
},
"charactersLeft": "{count} character left | {count} characters left",
"doubleClickToRename": "Double-click to rename",
"editWorkspaceImage": "Edit workspace image",
"memberLimitDialog": {
"message": "All seats are filled. To invite someone new, remove a member, rescind an invite, or request more seats.",
"title": "Workspace is at the member limit"
},
"requestMore": "Request more",
"workflowQueuedDialog": {
"message": "Max workflow capacity reached. It'll start automatically as capacity opens up. If this happens often, you can also request for more capacity.",
"title": "Your workflow is queued"
},
"billingStatus": {
"ending": {
"body": "Members keep full access until then. Reactivate to keep your shared credits and seats.",
"reactivate": "Reactivate plan",
"title": "Your team plan ends on {date}"
},
"outOfCredits": {
"addCredits": "Add credits",
"body": "Your team has used all its credits. Add more credits to continue generating or wait until credits refill on {date}.",
"bodyNoDate": "Your team has used all its credits. Add more credits to continue generating.",
"dismiss": "Dismiss",
"title": "Out of credits"
},
"paused": {
"body": "This workspace's subscription is paused. Update payment to resume.",
"memberBody": "This workspace's subscription is paused. Your workspace admins need to update the payment method.",
"title": "Subscription paused"
},
"updatePayment": "Update payment",
"warning": {
"body": "Your last payment didn't go through. Your subscription will pause on {date} unless payment is updated.",
"title": "Payment declined"
}
},
"overview": {
"changePlan": "Change plan",
"inactive": {
"reactivate": "Reactivate plan",
"subtitle": "Reactivate your team plan to add more members and run workflows",
"subtitleEnterprise": "Reactivate your enterprise plan to add more members and run workflows",
"title": "Inactive team subscription",
"titleEnterprise": "Inactive enterprise subscription"
},
"learnMore": "Learn more",
"managePayment": "Manage payment",
"messageSupport": "Message support",
"paused": "Paused",
"perMonth": "mo",
"pricingTable": "Partner Nodes pricing table",
"renewsOn": "Renews on {date}",
"seeMore": "See more",
"snapshot": {
"creditsUsed": "Credits used",
"empty": {
"recentActivity": "No activity yet",
"topSpenders": "No credits used yet this month"
},
"lastActivity": "Last activity",
"recentActivity": "Recent activity",
"topSpenders": "Top spenders",
"user": "User"
},
"nextInvoice": "Next month invoice",
"usd": "USD"
},
"invoices": {
"fullHistory": "Full invoice history"
},
"planCredits": {
"tabs": {
"invoices": "Invoices",
"overview": "Credits",
"activity": "Activity"
}
},
"autoReload": {
"badge": {
"off": "Off",
"paused": "Paused"
},
"dialog": {
"allowsReloads": "Allows {count} reload /mo | Allows {count} reloads /mo",
"amountLabel": "Add this amount of credits:",
"budgetPlaceholderCredits": "Enter an amount of credits",
"budgetPlaceholderUsd": "Enter an amount of dollars",
"budgetToggleHint": "Limit how much is auto-reloaded per month",
"budgetToggleLabel": "Monthly budget",
"cancel": "Cancel",
"minReload": "Minimum amount is {amount}",
"thresholdLabel": "When credits drop below:",
"title": "Auto-reload credits",
"update": "Update"
},
"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"
},
"enabled": "Enabled",
"subtitle": "Automatically add credits when your balance runs low, within an optional monthly budget.",
"tile": {
"label": "Auto-reload",
"monthlyBudget": "Monthly budget",
"percentSpent": "{percent}% spent",
"spentOfBudget": "{spent} of {budget}",
"whenBelow": "when credits drop below"
},
"title": "Credit auto-reload"
},
"activity": {
"columns": {
"creditsUsed": "Credits",
"date": "Date",
"eventDetails": "Event details",
"eventType": "Event type",
"user": "User"
},
"fullActivity": "Full activity",
"hoverCard": {
"lastActivity": "Last activity",
"partnerNodeUsed": "Partner node used",
"totalCreditsUsed": "Total credits used"
},
"perUserHint": "Looking for total usage per user?",
"seeMembers": "See Members",
"empty": "No activity yet."
}
},
"teamWorkspacesDialog": {
@@ -2990,7 +3136,7 @@
"newWorkspace": "New workspace",
"namePlaceholder": "e.g. Marketing Team",
"createWorkspace": "Create workspace",
"nameValidationError": "Name must be 150 characters using letters, numbers, spaces, or common punctuation."
"nameValidationError": "Name must be 130 characters using letters, numbers, spaces, or common punctuation."
},
"workspaceSwitcher": {
"switchWorkspace": "Switch workspace",
@@ -3000,7 +3146,8 @@
"roleMember": "Member",
"createWorkspace": "Create a workspace",
"maxWorkspacesReached": "You can only own 10 workspaces. Delete one to create a new one.",
"failedToSwitch": "Failed to switch workspace"
"failedToSwitch": "Failed to switch workspace",
"roleAdmin": "Admin"
},
"selectionToolbox": {
"executeButton": {

View File

@@ -9,20 +9,6 @@ import { useAssetsStore } from '@/stores/assetsStore'
const mockAssetsByKey = vi.hoisted(() => new Map<string, AssetItem[]>())
const mockLoadingByKey = vi.hoisted(() => new Map<string, boolean>())
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: false }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get supportsModelTypeTags() {
return mockSupportsModelTypeTags.value
},
get modelUploadButtonEnabled() {
return false
}
}
})
}))
vi.mock('@/i18n', () => ({
t: (key: string, params?: Record<string, string>) =>
@@ -228,7 +214,6 @@ describe('AssetBrowserModal', () => {
vi.resetAllMocks()
mockAssetsByKey.clear()
mockLoadingByKey.clear()
mockSupportsModelTypeTags.value = false
})
describe('Integration with useAssetBrowser', () => {
@@ -435,20 +420,5 @@ describe('AssetBrowserModal', () => {
'assetBrowser.allCategory:{"category":"Checkpoints"}'
)
})
it('strips the model_type: prefix from the title when the flag is on', async () => {
mockSupportsModelTypeTags.value = true
const assets = [
createTestAsset('asset1', 'Model A', 'model_type:checkpoints')
]
mockAssetsByKey.set('CheckpointLoaderSimple', assets)
renderModal({ nodeType: 'CheckpointLoaderSimple' })
await flushPromises()
expect(screen.getByTestId('modal-title').textContent).toBe(
'assetBrowser.allCategory:{"category":"Checkpoints"}'
)
})
})
})

View File

@@ -100,7 +100,6 @@ import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import Button from '@/components/ui/button/Button.vue'
import BaseModalLayout from '@/components/widget/layout/BaseModalLayout.vue'
import LeftSidePanel from '@/components/widget/panel/LeftSidePanel.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
import AssetFilterBar from '@/platform/assets/components/AssetFilterBar.vue'
import AssetGrid from '@/platform/assets/components/AssetGrid.vue'
@@ -110,14 +109,12 @@ import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getPrimaryCategoryTag } from '@/platform/assets/utils/assetMetadataUtils'
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
import { useAssetsStore } from '@/stores/assetsStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { OnCloseKey } from '@/types/widgetTypes'
const { t } = useI18n()
const { flags } = useFeatureFlags()
const assetStore = useAssetsStore()
const modelToNodeStore = useModelToNodeStore()
const breakpoints = useBreakpoints(breakpointsTailwind)
@@ -194,21 +191,9 @@ const focusedAsset = ref<AssetDisplayItem | null>(null)
const isRightPanelOpen = ref(false)
const primaryCategoryTag = computed(() => {
const modelTypeMode = flags.supportsModelTypeTags
// A node-typed picker is FOR a category; title off that category rather
// than guessing from the first asset, whose first model_type value may be
// a different category it shares a root with.
if (modelTypeMode && props.nodeType) {
const mapped = modelToNodeStore.getCategoryForNodeType(props.nodeType)
if (mapped) return mapped
}
const assets = fetchedAssets.value ?? []
// Covered assets title off the model_type value they group under (so title
// and grouping cannot diverge); uncovered assets keep the legacy verbatim
// first tag.
const tagFromAssets = assets
.map((asset) => getPrimaryCategoryTag(asset, modelTypeMode))
.map((asset) => asset.tags?.find((tag) => tag !== 'models'))
.find((tag): tag is string => typeof tag === 'string' && tag.length > 0)
if (tagFromAssets) return tagFromAssets

View File

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

View File

@@ -1,5 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { render, screen } from '@testing-library/vue'
@@ -14,14 +14,6 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
})
}))
const mockDistribution = vi.hoisted(() => ({ isCloud: false }))
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
...(await importOriginal<object>()),
get isCloud() {
return mockDistribution.isCloud
}
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -49,10 +41,6 @@ describe('ModelInfoPanel', () => {
...overrides
})
afterEach(() => {
mockDistribution.isCloud = false
})
function renderPanel(asset: AssetDisplayItem) {
return render(ModelInfoPanel, {
props: { asset },
@@ -150,18 +138,6 @@ describe('ModelInfoPanel', () => {
).toBeInTheDocument()
})
it('shows an editable model type dropdown for a mutable asset on cloud', () => {
mockDistribution.isCloud = true
renderPanel(createMockAsset({ is_immutable: false }))
expect(screen.getByRole('combobox')).toBeInTheDocument()
})
it('keeps the model type read-only on core even for a mutable asset', () => {
mockDistribution.isCloud = false
renderPanel(createMockAsset({ is_immutable: false }))
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
})
it('renders base models field', () => {
const asset = createMockAsset({
user_metadata: { base_model: ['SDXL'] }

View File

@@ -71,7 +71,7 @@
</span>
</template>
<ModelInfoField :label="t('assetBrowser.modelInfo.modelType')">
<Select v-if="isModelTypeEditable" v-model="selectedModelType">
<Select v-if="!isImmutable" v-model="selectedModelType">
<SelectTrigger class="w-full">
<SelectValue
:placeholder="t('assetBrowser.modelInfo.selectModelType')"
@@ -215,7 +215,6 @@ import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
@@ -230,19 +229,17 @@ import TagsInputItemDelete from '@/components/ui/tags-input/TagsInputItemDelete.
import TagsInputItemText from '@/components/ui/tags-input/TagsInputItemText.vue'
import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBrowser'
import { useModelTypes } from '@/platform/assets/composables/useModelTypes'
import { isCloud } from '@/platform/distribution/types'
import type { AssetUserMetadata } from '@/platform/assets/schemas/assetSchema'
import {
buildModelTypeTagUpdate,
getAssetAdditionalTags,
getAssetBaseModels,
getAssetDescription,
getAssetDisplayName,
getAssetFilename,
getAssetModelType,
getAssetSourceUrl,
getAssetTriggerPhrases,
getAssetUserDescription,
getEditableModelType,
getSourceName
} from '@/platform/assets/utils/assetMetadataUtils'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -268,7 +265,6 @@ const { asset, cacheKey, selectContentStyle } = defineProps<{
}>()
const assetsStore = useAssetsStore()
const { flags } = useFeatureFlags()
const { modelTypes } = useModelTypes()
const pendingUpdates = ref<AssetUserMetadata>({})
@@ -276,9 +272,6 @@ const pendingModelType = ref<string | undefined>(undefined)
const isEditingDisplayName = ref(false)
const isImmutable = computed(() => asset.is_immutable ?? true)
// Retagging a model rewrites its asset tags; core is filesystem-backed and does
// not yet move the file to match, so the model type is read-only off-cloud.
const isModelTypeEditable = computed(() => !isImmutable.value && isCloud)
const displayName = computed(
() => pendingUpdates.value.name ?? getAssetDisplayName(asset)
)
@@ -325,17 +318,12 @@ function handleDisplayNameEdit(newName: string) {
}
const debouncedSaveModelType = useDebounceFn((newModelType: string) => {
if (!isModelTypeEditable.value) return
const currentModelType = getEditableModelType(
asset,
flags.supportsModelTypeTags
)
if (isImmutable.value) return
const currentModelType = getAssetModelType(asset)
if (currentModelType === newModelType) return
const newTags = buildModelTypeTagUpdate(
asset,
newModelType,
flags.supportsModelTypeTags
)
const newTags = asset.tags
.filter((tag) => tag !== currentModelType)
.concat(newModelType)
assetsStore.updateAssetTags(asset, newTags, cacheKey)
}, 500)
@@ -357,10 +345,7 @@ const userDescription = computed({
})
const selectedModelType = computed({
get: () =>
pendingModelType.value ??
getEditableModelType(asset, flags.supportsModelTypeTags) ??
undefined,
get: () => pendingModelType.value ?? getAssetModelType(asset) ?? undefined,
set: (value: string | undefined) => {
if (!value) return
pendingModelType.value = value

View File

@@ -25,22 +25,10 @@ vi.mock('@/i18n', () => ({
d: (date: Date) => date.toLocaleDateString()
}))
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: false }))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get supportsModelTypeTags() {
return mockSupportsModelTypeTags.value
}
}
})
}))
describe('useAssetBrowser', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.restoreAllMocks()
mockSupportsModelTypeTags.value = false
})
// Test fixtures - minimal data focused on functionality being tested
@@ -150,25 +138,6 @@ describe('useAssetBrowser', () => {
})
})
it('strips the model_type: prefix from the badge when the flag is on', () => {
mockSupportsModelTypeTags.value = true
const apiAsset = createApiAsset({
tags: ['models', 'model_type:checkpoints', 'sdxl']
})
const { filteredAssets } = useAssetBrowser(ref([apiAsset]))
const result = filteredAssets.value[0]
expect(result.badges).toContainEqual({
label: 'checkpoints',
type: 'type'
})
expect(result.badges).not.toContainEqual({
label: 'model_type:checkpoints',
type: 'type'
})
})
it('handles tags with multiple slashes in badges', () => {
const apiAsset = createApiAsset({
tags: ['models', 'checkpoint/subfolder/model-name']
@@ -699,34 +668,6 @@ describe('useAssetBrowser', () => {
])
})
it('groups by model_type:* value and ignores other tags when the flag is on', () => {
mockSupportsModelTypeTags.value = true
const assets = [
createApiAsset({ tags: ['models', 'model_type:checkpoints', 'sdxl'] }),
createApiAsset({ tags: ['models', 'model_type:LLM'] })
]
const { navItems } = useAssetBrowser(ref(assets))
const typeGroup = navItems.value[2] as { items: { id: string }[] }
expect(typeGroup.items.map((i) => i.id)).toEqual(['LLM', 'checkpoints'])
})
it('ignores model_type: and groups by bare tags when the flag is off', () => {
const assets = [
createApiAsset({ tags: ['models', 'model_type:checkpoints'] }),
createApiAsset({ tags: ['models', 'model_type:LLM'] })
]
const { navItems } = useAssetBrowser(ref(assets))
const typeGroup = navItems.value[2] as { items: { id: string }[] }
expect(typeGroup.items.map((i) => i.id)).toEqual([
'model_type:LLM',
'model_type:checkpoints'
])
})
it('handles assets with no category tag', () => {
const assets = [
createApiAsset({ tags: ['models'] }), // No second tag

View File

@@ -19,13 +19,10 @@ import {
} from '@/platform/assets/utils/assetFilterUtils'
import {
getAssetBaseModels,
getAssetCategories,
getAssetFilename,
getAssetTypeBadges
getAssetFilename
} from '@/platform/assets/utils/assetMetadataUtils'
import { MODELS_TAG } from '@/platform/assets/services/assetService'
import { sortAssets } from '@/platform/assets/utils/assetSortUtils'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { NavGroupData, NavItemData } from '@/types/navTypes'
@@ -46,19 +43,18 @@ export interface AssetDisplayItem extends AssetItem {
}
}
const displayItemCache = new WeakMap<
AssetItem,
{ modelTypeMode: boolean; item: AssetDisplayItem }
>()
const displayItemCache = new WeakMap<AssetItem, AssetDisplayItem>()
function buildDisplayItem(
asset: AssetItem,
modelTypeMode: boolean
): AssetDisplayItem {
function buildDisplayItem(asset: AssetItem): AssetDisplayItem {
const badges: AssetBadge[] = []
for (const typeBadge of getAssetTypeBadges(asset, modelTypeMode)) {
badges.push({ label: typeBadge, type: 'type' })
const typeTag = asset.tags.find((tag) => tag !== 'models')
if (typeTag) {
const badgeLabel = typeTag.includes('/')
? typeTag.substring(typeTag.indexOf('/') + 1)
: typeTag
badges.push({ label: badgeLabel, type: 'type' })
}
for (const model of getAssetBaseModels(asset)) {
@@ -79,15 +75,12 @@ function buildDisplayItem(
}
}
function transformAssetForDisplay(
asset: AssetItem,
modelTypeMode: boolean
): AssetDisplayItem {
function transformAssetForDisplay(asset: AssetItem): AssetDisplayItem {
const cached = displayItemCache.get(asset)
if (cached && cached.modelTypeMode === modelTypeMode) return cached.item
const item = buildDisplayItem(asset, modelTypeMode)
displayItemCache.set(asset, { modelTypeMode, item })
return item
if (cached) return cached
const built = buildDisplayItem(asset)
displayItemCache.set(asset, built)
return built
}
/**
@@ -100,7 +93,6 @@ export function useAssetBrowser(
const assets = computed<AssetItem[]>(() => assetsSource.value ?? [])
const assetDownloadStore = useAssetDownloadStore()
const { sessionDownloadCount } = storeToRefs(assetDownloadStore)
const { flags } = useFeatureFlags()
// State
const searchQuery = ref('')
@@ -130,10 +122,12 @@ export function useAssetBrowser(
})
const typeCategories = computed<NavItemData[]>(() => {
const modelTypeMode = flags.supportsModelTypeTags
const categories = assets.value
.filter((asset) => asset.tags.includes(MODELS_TAG))
.flatMap((asset) => getAssetCategories(asset, modelTypeMode))
.flatMap((asset) =>
asset.tags.filter((tag) => tag !== MODELS_TAG && tag.length > 0)
)
.map((tag) => tag.split('/')[0])
return Array.from(new Set(categories))
.sort()
@@ -197,9 +191,7 @@ export function useAssetBrowser(
// Category-filtered assets for filter options (before search/format/base model filters)
const categoryFilteredAssets = computed(() => {
return assets.value.filter(
filterByCategory(selectedCategory.value, flags.supportsModelTypeTags)
)
return assets.value.filter(filterByCategory(selectedCategory.value))
})
const { availableFileFormats, availableBaseModels } = useAssetFilterOptions(
@@ -256,10 +248,7 @@ export function useAssetBrowser(
const sortedAssets = sortAssets(filtered, filters.value.sortBy)
// Transform to display format
const modelTypeMode = flags.supportsModelTypeTags
return sortedAssets.map((asset) =>
transformAssetForDisplay(asset, modelTypeMode)
)
return sortedAssets.map(transformAssetForDisplay)
})
function updateFilters(newFilters: AssetFilterState) {

View File

@@ -38,10 +38,7 @@ vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
addEventListener: vi.fn(),
apiURL: vi.fn((path: string) => path),
getServerFeature: vi.fn(
(_name: string, defaultValue?: unknown) => defaultValue
)
apiURL: vi.fn((path: string) => path)
}
}))
@@ -282,43 +279,6 @@ describe('useUploadModelWizard', () => {
expect(result?.modelType).toBe('checkpoints')
})
it('namespaces the tag but keeps user_metadata.model_type bare when the backend supports it', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
const { api } = await import('@/scripts/api')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'model_type:checkpoints']
}
})
vi.mocked(api.getServerFeature).mockImplementation((name, defaultValue) =>
name === 'supports_model_type_tags' ? true : defaultValue
)
try {
const wizard = setupUploadModelWizard(modelTypes, {
requiredModelType: 'checkpoints'
})
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
await wizard.uploadModel()
const uploadArg = vi.mocked(assetService.uploadAssetAsync).mock
.calls[0][0]
expect(uploadArg.tags).toEqual(['models', 'model_type:checkpoints'])
expect(uploadArg.user_metadata?.model_type).toBe('checkpoints')
// The namespaced returned tag must not trip the required-type guard.
expect(wizard.uploadTypeMismatch.value).toBeNull()
} finally {
vi.mocked(api.getServerFeature).mockImplementation(
(_name, defaultValue) => defaultValue
)
}
})
it('returns the synced asset filename for sync imports', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
@@ -387,65 +347,6 @@ describe('useUploadModelWizard', () => {
})
})
it('treats a namespaced model_type: tag as satisfying the required type', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-1',
name: 'model.safetensors',
tags: ['models', 'model_type:checkpoints']
}
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
]),
{ requiredModelType: 'checkpoints' }
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
const result = await wizard.uploadModel()
expect(result).not.toBeNull()
expect(wizard.uploadTypeMismatch.value).toBeNull()
})
it('strips the model_type: prefix from the imported-type label on a real mismatch', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')
vi.mocked(assetService.uploadAssetAsync).mockResolvedValue({
type: 'sync',
asset: {
id: 'asset-lora',
name: 'model.safetensors',
tags: ['models', 'model_type:loras']
}
})
const wizard = setupUploadModelWizard(
ref([
{ name: 'Checkpoint', value: 'checkpoints' },
{ name: 'LoRA', value: 'loras' }
]),
{ requiredModelType: 'checkpoints' }
)
wizard.wizardData.value.url = 'https://civitai.com/models/12345'
const result = await wizard.uploadModel()
expect(result).toBeNull()
expect(wizard.uploadTypeMismatch.value).toEqual({
importedModelType: 'loras',
importedModelTypeLabel: 'LoRA',
requiredModelType: 'checkpoints',
requiredModelTypeLabel: 'Checkpoint'
})
})
it('does not block sync imports as mismatches without a required model type', async () => {
const { assetService } =
await import('@/platform/assets/services/assetService')

View File

@@ -2,7 +2,6 @@ import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st } from '@/i18n'
import { civitaiImportSource } from '@/platform/assets/importSources/civitaiImportSource'
import { huggingfaceImportSource } from '@/platform/assets/importSources/huggingfaceImportSource'
@@ -12,11 +11,7 @@ import type {
} from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import type { ImportSource } from '@/platform/assets/types/importSource'
import {
getAssetFilename,
stripModelTypePrefix,
toModelTypeTag
} from '@/platform/assets/utils/assetMetadataUtils'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { validateSourceUrl } from '@/platform/assets/utils/importSourceUtil'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import { useAssetsStore } from '@/stores/assetsStore'
@@ -73,7 +68,6 @@ export function useUploadModelWizard(
options: UploadModelWizardOptions = {}
) {
const { t } = useI18n()
const { flags } = useFeatureFlags()
const assetsStore = useAssetsStore()
const assetDownloadStore = useAssetDownloadStore()
const modelToNodeStore = useModelToNodeStore()
@@ -277,22 +271,19 @@ export function useUploadModelWizard(
}
function getImportedModelType(asset: AssetItem): string | undefined {
const subtypeTags = asset.tags
.filter((tag) => tag !== MODEL_ROOT_TAG)
.map(stripModelTypePrefix)
return (
subtypeTags.find((tag) =>
const knownType = asset.tags.find(
(tag) =>
tag !== MODEL_ROOT_TAG &&
modelTypes.value.some((type) => type.value === tag)
) ?? subtypeTags[0]
)
return knownType ?? asset.tags.find((tag) => tag !== MODEL_ROOT_TAG)
}
function blockMismatchedImportedModel(
asset: AssetItem,
requiredType: string
): boolean {
if (asset.tags.map(stripModelTypePrefix).includes(requiredType))
return false
if (asset.tags.includes(requiredType)) return false
const importedType = getImportedModelType(asset)
uploadStatus.value = 'error'
@@ -326,11 +317,7 @@ export function useUploadModelWizard(
try {
const modelType = resolvedModelType.value
const subtypeTag =
modelType && flags.supportsModelTypeTags
? toModelTypeTag(modelType)
: modelType
const tags = subtypeTag ? [MODEL_ROOT_TAG, subtypeTag] : [MODEL_ROOT_TAG]
const tags = modelType ? ['models', modelType] : ['models']
const filename =
wizardData.value.metadata?.filename ||
wizardData.value.metadata?.name ||

View File

@@ -11,8 +11,6 @@ const zAsset = z.object({
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
display_name: z.string().optional(),
/** Path within the model's category folder, i.e. the value a loader widget expects. */
loader_path: z.string().nullish(),
preview_url: z.string().optional(),
thumbnail_url: z.string().optional(),
created_at: z.string().optional(),
@@ -29,6 +27,11 @@ const zAssetResponse = zListAssetsResponse
assets: z.array(zAsset)
})
const zModelFolder = z.object({
name: z.string(),
folders: z.array(z.string())
})
// Zod schema for ModelFile to align with interface
const zModelFile = z.object({
name: z.string(),
@@ -97,6 +100,7 @@ export type AssetItem = z.infer<typeof zAsset>
export type AssetResponse = z.infer<typeof zAssetResponse>
export type AssetMetadata = z.infer<typeof zAssetMetadata>
export type AsyncUploadResponse = z.infer<typeof zAsyncUploadResponse>
export type ModelFolder = z.infer<typeof zModelFolder>
export type ModelFile = z.infer<typeof zModelFile>
/** Payload for updating an asset via PUT /assets/:id */
@@ -128,10 +132,4 @@ export type TagsOperationResult = z.infer<typeof tagsOperationResultSchema>
export interface ModelFolderInfo {
name: string
folders: string[]
/**
* The folder's raw registered extension allowlist from
* `/experiment/models`. An empty array means match-all; absent on older
* backends.
*/
extensions?: string[]
}

View File

@@ -12,7 +12,6 @@ import { api } from '@/scripts/api'
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
const mockSettingStoreGet = vi.hoisted(() => vi.fn(() => false))
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: true }))
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
@@ -20,16 +19,6 @@ vi.mock('@/platform/distribution/types', () => ({
}
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get supportsModelTypeTags() {
return mockSupportsModelTypeTags.value
}
}
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: vi.fn(() => ({
get: mockSettingStoreGet
@@ -51,9 +40,7 @@ vi.mock('@/stores/modelToNodeStore', () => {
vi.mock('@/scripts/api', () => ({
api: {
fetchApi: vi.fn(),
addCustomEventListener: vi.fn(),
removeCustomEventListener: vi.fn()
fetchApi: vi.fn()
}
}))
@@ -100,7 +87,6 @@ function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
name: 'model.safetensors',
loader_path: overrides.name ?? 'model.safetensors',
tags: ['models'],
...overrides
}
@@ -430,330 +416,33 @@ describe(assetService.deleteAsset, () => {
})
})
describe(assetService.getAssetModels, () => {
describe(assetService.getAssetModelFolders, () => {
beforeEach(() => {
vi.clearAllMocks()
assetService.invalidateModelBuckets()
mockSupportsModelTypeTags.value = true
})
it('walks the models tag once, excluding missing assets', async () => {
it('requests missing-tag exclusion and returns alphabetical unique folders without include_public', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({ id: 'a', tags: ['models', 'model_type:checkpoints'] })
validAsset({ id: 'a', tags: ['models', 'loras'] }),
validAsset({ id: 'b', tags: ['models', 'checkpoints'] }),
validAsset({ id: 'c', tags: ['models', 'configs'] }),
validAsset({ id: 'e', tags: ['models', 'loras'] })
])
)
await assetService.getAssetModels('checkpoints')
const folders = await assetService.getAssetModelFolders()
expect(folders).toEqual([
{ name: 'checkpoints', folders: [] },
{ name: 'loras', folders: [] }
])
expect(fetchApiMock).toHaveBeenCalledTimes(1)
const requestedUrl = fetchApiMock.mock.calls[0]?.[0] as string
const params = new URL(requestedUrl, 'http://localhost').searchParams
expect(params.get('include_tags')).toBe('models')
expect(params.has('include_public')).toBe(false)
expect(params.get('exclude_tags')).toBe(MISSING_TAG)
})
it('buckets by bare tags when model_type tags are unsupported', async () => {
mockSupportsModelTypeTags.value = false
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'a',
name: 'a.safetensors',
tags: ['models', 'checkpoints']
})
])
)
const models = await assetService.getAssetModels('checkpoints')
expect(models).toEqual([{ name: 'a.safetensors', pathIndex: 0 }])
})
it('drops uncategorized model assets with a warning', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'ok',
name: 'ok.safetensors',
tags: ['models', 'model_type:loras']
}),
validAsset({
id: 'uncat',
name: 'orphan.safetensors',
tags: ['models']
})
])
)
const loras = await assetService.getAssetModels('loras')
expect(loras).toEqual([{ name: 'ok.safetensors', pathIndex: 0 }])
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('orphan.safetensors')
)
warn.mockRestore()
})
it('maps loader_path and drops unloadable assets without one', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'nested',
name: 'model.safetensors',
loader_path: 'sdxl/model.safetensors',
tags: ['models', 'model_type:checkpoints']
}),
validAsset({
id: 'orphan',
name: 'orphan.safetensors',
loader_path: null,
tags: ['models', 'model_type:checkpoints']
}),
validAsset({
id: 'other-folder',
name: 'lora.safetensors',
tags: ['models', 'model_type:loras']
})
])
)
const models = await assetService.getAssetModels('checkpoints')
expect(models).toEqual([{ name: 'sdxl/model.safetensors', pathIndex: 0 }])
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('orphan.safetensors')
)
warn.mockRestore()
})
it('drops assets whose loader path is traversal-shaped', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'evil',
name: 'evil.safetensors',
loader_path: '../../secrets/evil.safetensors',
tags: ['models', 'model_type:checkpoints']
}),
validAsset({
id: 'ok',
name: 'fine.safetensors',
tags: ['models', 'model_type:checkpoints']
})
])
)
const models = await assetService.getAssetModels('checkpoints')
expect(models).toEqual([{ name: 'fine.safetensors', pathIndex: 0 }])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unsafe'))
warn.mockRestore()
})
it('groups slashed bare tags by their top-level segment', async () => {
mockSupportsModelTypeTags.value = false
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'slashed',
name: 'model1.safetensors',
tags: ['models', 'Chatterbox/subfolder1/model1']
})
])
)
const models = await assetService.getAssetModels('Chatterbox')
expect(models).toEqual([{ name: 'model1.safetensors', pathIndex: 0 }])
})
it('falls back to filename metadata then name on bare-tag backends', async () => {
mockSupportsModelTypeTags.value = false
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'cloud-hash',
name: 'blake3-content-hash',
loader_path: null,
user_metadata: { filename: 'sdxl/cloud-model.safetensors' },
tags: ['models', 'checkpoints']
}),
validAsset({
id: 'bare',
name: 'plain.safetensors',
loader_path: null,
tags: ['models', 'checkpoints']
})
])
)
const models = await assetService.getAssetModels('checkpoints')
expect(models).toEqual([
{ name: 'sdxl/cloud-model.safetensors', pathIndex: 0 },
{ name: 'plain.safetensors', pathIndex: 0 }
])
})
it('orders each folder subdirectories-first then files, alphabetically', async () => {
const checkpointAsset = (id: string, loaderPath: string) =>
validAsset({
id,
name: loaderPath.split('/').pop()!,
loader_path: loaderPath,
tags: ['models', 'model_type:checkpoints']
})
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
checkpointAsset('1', 'sdxl/base.safetensors'),
checkpointAsset('2', 'v1-5.safetensors'),
checkpointAsset('3', 'sdxl/refiner.safetensors'),
checkpointAsset('4', 'anything.safetensors'),
checkpointAsset('5', 'dynamicrafter/model.safetensors')
])
)
const models = await assetService.getAssetModels('checkpoints')
expect(models.map((m) => m.name)).toEqual([
'dynamicrafter/model.safetensors',
'sdxl/base.safetensors',
'sdxl/refiner.safetensors',
'anything.safetensors',
'v1-5.safetensors'
])
})
it('does not let a stale in-flight walk overwrite an invalidated cache', async () => {
let resolveStaleWalk!: (response: Response) => void
fetchApiMock.mockReturnValueOnce(
new Promise<Response>((resolve) => {
resolveStaleWalk = resolve
})
)
const staleRead = assetService.getAssetModels('checkpoints')
assetService.invalidateModelBuckets()
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'fresh',
name: 'fresh.safetensors',
tags: ['models', 'model_type:checkpoints']
})
])
)
const freshModels = await assetService.getAssetModels('checkpoints')
expect(freshModels.map((m) => m.name)).toEqual(['fresh.safetensors'])
resolveStaleWalk(
buildAssetListResponse([
validAsset({
id: 'stale',
name: 'stale.safetensors',
tags: ['models', 'model_type:checkpoints']
})
])
)
await staleRead
const cachedModels = await assetService.getAssetModels('checkpoints')
expect(cachedModels.map((m) => m.name)).toEqual(['fresh.safetensors'])
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
it('places multi-category assets in every folder from a single walk', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([
validAsset({
id: 'shared',
name: 'dual_use.safetensors',
loader_path: 'dual_use.safetensors',
tags: [
'models',
'model_type:checkpoints',
'model_type:diffusion_models'
]
})
])
)
const checkpoints = await assetService.getAssetModels('checkpoints')
const diffusion = await assetService.getAssetModels('diffusion_models')
expect(checkpoints).toEqual([
{ name: 'dual_use.safetensors', pathIndex: 0 }
])
expect(diffusion).toEqual([{ name: 'dual_use.safetensors', pathIndex: 0 }])
// Both folder reads resolve from a single memoized models walk.
expect(fetchApiMock).toHaveBeenCalledTimes(1)
})
})
describe(assetService.onModelsScanned, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('invokes the callback when the scan event fires and unsubscribes cleanly', () => {
const callback = vi.fn()
const unsubscribe = assetService.onModelsScanned(callback)
const [eventType, handler] = vi.mocked(api.addCustomEventListener).mock
.calls[0]!
expect(eventType).toBe('assets.seed.fast_complete')
handler!(new CustomEvent(eventType))
expect(callback).toHaveBeenCalledOnce()
unsubscribe()
expect(api.removeCustomEventListener).toHaveBeenCalledWith(
eventType,
handler
)
})
})
describe(assetService.seedModelAssets, () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('POSTs the models root to the seed endpoint', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({ status: 'started' }, { status: 202 })
)
await assetService.seedModelAssets()
expect(fetchApiMock).toHaveBeenCalledWith('/assets/seed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roots: ['models'] })
})
})
it('treats an already-running scan (409) as success', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({ status: 'already_running' }, { ok: false, status: 409 })
)
await expect(assetService.seedModelAssets()).resolves.toBeUndefined()
})
it('throws on other error statuses', async () => {
fetchApiMock.mockResolvedValueOnce(
buildResponse({}, { ok: false, status: 500 })
)
await expect(assetService.seedModelAssets()).rejects.toThrow('500')
})
})
describe(assetService.updateAsset, () => {

View File

@@ -1,11 +1,9 @@
import { fromZodError } from 'zod-validation-error'
import { z } from 'zod'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { st } from '@/i18n'
import {
assetFilenameSchema,
assetItemSchema,
assetResponseSchema,
asyncUploadResponseSchema,
@@ -19,9 +17,9 @@ import type {
AssetUpdatePayload,
AsyncUploadResponse,
ModelFile,
ModelFolder,
TagsOperationResult
} from '@/platform/assets/schemas/assetSchema'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
@@ -182,7 +180,6 @@ function getLocalizedErrorMessage(errorCode: string): string {
}
const ASSETS_ENDPOINT = '/assets'
const ASSETS_SEED_ENDPOINT = '/assets/seed'
const ASSETS_DOWNLOAD_ENDPOINT = '/assets/download'
const ASSETS_EXPORT_ENDPOINT = '/assets/export'
const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make sure "Comfy.Assets.UseAssetAPI" is set to "false" in your ComfyUI Settings.\n`
@@ -190,8 +187,6 @@ const DEFAULT_LIMIT = 500
const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
export const MODELS_TAG = 'models'
/** Prefix for the namespaced tag that carries a model's folder category, e.g. `model_type:checkpoints`. */
const MODEL_TYPE_TAG_PREFIX = 'model_type:'
export const INPUT_TAG = 'input'
export const OUTPUT_TAG = 'output'
/** Asset tag used by the backend for placeholder records that are not installed. */
@@ -214,48 +209,6 @@ function normalizeAssetTags(tags: string[]): string[] {
return tags.map((tag) => tag.trim()).filter(Boolean)
}
/**
* Resolves the model folder a tag represents, or undefined when the tag is not
* a folder category. `supports_model_type_tags` backends carry the category as
* a namespaced `model_type:<folder>` tag; older backends mint bare tags, which
* may carry subfolder paths (e.g. `Chatterbox/sub/model`) and group by their
* top-level segment, matching the asset browser's legacy grouping.
*/
function modelFolderFromTag(
tag: string,
modelTypeMode: boolean
): string | undefined {
if (modelTypeMode) {
return tag.startsWith(MODEL_TYPE_TAG_PREFIX)
? tag.slice(MODEL_TYPE_TAG_PREFIX.length)
: undefined
}
if (tag === MODELS_TAG || tag.length === 0) return undefined
return tag.split('/')[0]
}
/**
* Orders loader paths as subdirectories before files at every level,
* alphabetical within each group. The asset API returns models in storage
* order, which would otherwise interleave root-level files with folder
* contents in the sidebar tree.
*/
function compareLoaderPaths(a: string, b: string): number {
const aSegments = a.split('/')
const bSegments = b.split('/')
const sharedDepth = Math.min(aSegments.length, bSegments.length)
for (let i = 0; i < sharedDepth; i++) {
const aIsFile = i === aSegments.length - 1
const bIsFile = i === bSegments.length - 1
if (aIsFile !== bIsFile) return aIsFile ? 1 : -1
const order = aSegments[i].localeCompare(bSegments[i], undefined, {
numeric: true
})
if (order !== 0) return order
}
return 0
}
async function withCallerAbort<T>(
promise: Promise<T>,
signal?: AbortSignal
@@ -316,26 +269,6 @@ function createAssetService() {
let inputAssetsIncludingPublicRequestId = 0
let pendingInputAssetsIncludingPublic: Promise<AssetItem[]> | null = null
/**
* Model assets bucketed by folder category, built from a single walk of the
* `models` tag rather than a fetch per category. Shared by the folder list
* and per-folder listings so the sidebar loads every model in one pass.
*/
let modelBuckets: Map<string, AssetItem[]> | null = null
let modelBucketsRequestId = 0
let pendingModelBuckets: Promise<Map<string, AssetItem[]>> | null = null
/**
* Discards the cached model buckets so the next read re-walks the models
* tag. Bumping the request id keeps a walk that was already in flight from
* repopulating the cache with pre-invalidation data.
*/
function invalidateModelBuckets(): void {
modelBucketsRequestId++
modelBuckets = null
pendingModelBuckets = null
}
/** Invalidates the cached public-inclusive input assets without aborting in-flight readers. */
function invalidateInputAssetsIncludingPublic(): void {
inputAssetsIncludingPublicRequestId++
@@ -397,156 +330,51 @@ function createAssetService() {
return validateAssetResponse(data)
}
/**
* Walks every `models`-tagged asset once and buckets each into the folder
* categories carried by its `model_type:` tags. A single asset lands in every
* category it is tagged with (e.g. a shared-root model in both `checkpoints`
* and `diffusion_models`). Which folders are actually shown is decided by
* `/experiment/models`; models with no category tag are dropped with a warning
* rather than hidden silently.
* Gets a list of model folder keys from the asset API
*
* Logic:
* 1. Extract directory names directly from asset tags
* 2. Filter out blacklisted directories
* 3. Return alphabetically sorted directories with assets
*
* @returns The list of model folder keys
*/
async function buildModelBuckets(): Promise<Map<string, AssetItem[]>> {
const assets = await getAllAssetsByTag(MODELS_TAG, true)
const modelTypeMode = useFeatureFlags().flags.supportsModelTypeTags
const buckets = new Map<string, AssetItem[]>()
async function getAssetModelFolders(): Promise<ModelFolder[]> {
const data = await handleAssetRequest(
{ includeTags: [MODELS_TAG] },
'model folders'
)
for (const asset of assets) {
const folders = asset.tags
.map((tag) => modelFolderFromTag(tag, modelTypeMode))
.filter((folder): folder is string => folder !== undefined)
// Blacklist directories we don't want to show
const blacklistedDirectories = new Set(['configs'])
if (folders.length === 0) {
console.warn(
`Asset ${asset.id} (${asset.name}) is tagged '${MODELS_TAG}' but has no model category; skipping.`
)
continue
}
const folderTags = data.assets
.flatMap((asset) => asset.tags)
.filter((tag) => tag !== MODELS_TAG && !blacklistedDirectories.has(tag))
const discoveredFolders = new Set<string>(folderTags)
// On loader_path-contract backends a null loader_path marks an
// unloadable asset (e.g. an orphan): it must not mint a widget value,
// and `name` is deprecated for path semantics.
if (modelTypeMode && !asset.loader_path) {
console.warn(
`Asset ${asset.id} (${asset.name}) has no loader_path; skipping.`
)
continue
}
// The loader value flows into viewMetadata URLs and widget values, so a
// traversal-shaped path must not pass through even if the backend's own
// validation ever regresses.
const loaderValue = asset.loader_path ?? getAssetFilename(asset)
if (!assetFilenameSchema.safeParse(loaderValue).success) {
console.warn(
`Asset ${asset.id} (${asset.name}) has an unsafe loader path ('${loaderValue}'); skipping.`
)
continue
}
for (const folder of folders) {
const bucket = buckets.get(folder)
if (bucket) bucket.push(asset)
else buckets.set(folder, [asset])
}
}
for (const bucket of buckets.values()) {
bucket.sort((a, b) =>
compareLoaderPaths(
a.loader_path ?? getAssetFilename(a),
b.loader_path ?? getAssetFilename(b)
)
)
}
return buckets
}
/** Returns the memoized model buckets, walking the models tag on first read. */
async function loadModelBuckets(): Promise<Map<string, AssetItem[]>> {
if (modelBuckets) return modelBuckets
if (pendingModelBuckets) return pendingModelBuckets
const requestId = ++modelBucketsRequestId
const walk = async () => {
try {
const buckets = await buildModelBuckets()
if (requestId === modelBucketsRequestId) {
modelBuckets = buckets
}
return buckets
} finally {
if (requestId === modelBucketsRequestId) {
pendingModelBuckets = null
}
}
}
pendingModelBuckets = walk()
return pendingModelBuckets
// Return only discovered folders in alphabetical order
const sortedFolders = Array.from(discoveredFolders).toSorted()
return sortedFolders.map((name) => ({ name, folders: [] }))
}
/**
* Gets the models in the specified folder from the single models walk.
* Gets a list of models in the specified folder from the asset API
* @param folder The folder to list models from, such as 'checkpoints'
* @returns The list of model filenames within the specified folder
*/
async function getAssetModels(folder: string): Promise<ModelFile[]> {
const buckets = await loadModelBuckets()
return (buckets.get(folder) ?? []).map((asset) => ({
// `loader_path` is the category-relative path the loader widget expects
// and the source for the sidebar tree. Backends that predate it (bare-tag
// mode; today's cloud) fall back to the filename metadata — the same
// value the asset browser serializes — rather than `name`, which is a
// content hash on cloud.
name: asset.loader_path ?? getAssetFilename(asset),
// Asset records carry no root identity, so every model reports root 0.
// Known limitation on multi-root categories (extra_model_paths.yaml):
// preview reads target root 0 (wrong file or 404 for secondary-root
// files), and same-relative-path files in different roots collapse
// onto one sidebar row. Metadata is unaffected unless relative paths
// collide (/view_metadata searches roots in order without an index),
// as are loader widget values; lifting this needs the backend to carry
// root identity on assets.
const data = await handleAssetRequest(
{ includeTags: [MODELS_TAG, folder] },
`models for ${folder}`
)
return data.assets.map((asset) => ({
name: asset.name,
pathIndex: 0
}))
}
/**
* Asks the backend to rescan the model roots on disk so newly added files
* become assets. Fire-and-forget: the scan's fast (insert) phase already
* writes the category tags and filenames the sidebar needs and is announced
* by an `assets.seed.fast_complete` websocket event. A 409 means a scan is
* already running, which will emit the same event, so it is not an error.
*/
async function seedModelAssets(): Promise<void> {
const res = await api.fetchApi(ASSETS_SEED_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roots: ['models'] })
})
if (!res.ok && res.status !== 409) {
throw new Error(
`Unable to start asset scan: Server returned ${res.status}`
)
}
}
/**
* Subscribes to the backend's scan fast-phase completion broadcast — the
* moment newly scanned files' tags and loader paths become queryable. The
* wire-level event (`assets.seed.fast_complete`) is owned here; consumers
* receive a callback and an unsubscribe function.
*/
function onModelsScanned(callback: () => void | Promise<void>): () => void {
const handler = () => {
void callback()
}
api.addCustomEventListener('assets.seed.fast_complete', handler)
return () => {
api.removeCustomEventListener('assets.seed.fast_complete', handler)
}
}
/**
* Checks if a widget input should use the asset browser based on both input name and node comfyClass
*
@@ -1143,10 +971,8 @@ function createAssetService() {
}
return {
getAssetModelFolders,
getAssetModels,
invalidateModelBuckets,
onModelsScanned,
seedModelAssets,
isAssetAPIEnabled,
isAssetBrowserEligible,
shouldUseAssetBrowser,

View File

@@ -45,7 +45,7 @@ describe('assetFilterUtils properties', () => {
it('filterByCategory("all") accepts every asset', () => {
fc.assert(
fc.property(arbAssetItem, (asset) => {
expect(filterByCategory('all', false)(asset)).toBe(true)
expect(filterByCategory('all')(asset)).toBe(true)
})
)
})
@@ -56,7 +56,7 @@ describe('assetFilterUtils properties', () => {
fc.array(arbAssetItem, { maxLength: 30 }),
fc.stringMatching(/^[a-z]{1,8}$/),
(assets, category) => {
const filter = filterByCategory(category, false)
const filter = filterByCategory(category)
const result = assets.filter(filter)
expect(result.length).toBeLessThanOrEqual(assets.length)
for (const item of result) {

View File

@@ -26,54 +26,19 @@ function createAsset(
describe('filterByCategory', () => {
it.for([
{ category: 'all', tags: ['checkpoint'], mode: false, expected: true },
{
category: 'checkpoint',
tags: ['checkpoint'],
mode: false,
expected: true
},
{ category: 'lora', tags: ['checkpoint'], mode: false, expected: false },
{ category: 'all', tags: ['checkpoint'], expected: true },
{ category: 'checkpoint', tags: ['checkpoint'], expected: true },
{ category: 'lora', tags: ['checkpoint'], expected: false },
{
category: 'checkpoint',
tags: ['models', 'checkpoint/xl'],
mode: false,
expected: true
},
{
category: 'xl',
tags: ['models', 'checkpoint/xl'],
mode: false,
expected: false
},
{
category: 'checkpoints',
tags: ['models', 'model_type:checkpoints'],
mode: true,
expected: true
},
{
category: 'LLM',
tags: ['models', 'model_type:LLM'],
mode: true,
expected: true
},
{
category: 'sdxl',
tags: ['models', 'model_type:checkpoints', 'sdxl'],
mode: true,
expected: false
},
{
category: 'checkpoints',
tags: ['models', 'model_type:checkpoints'],
mode: false,
expected: false
}
{ category: 'xl', tags: ['models', 'checkpoint/xl'], expected: false }
])(
'category=$category tags=$tags mode=$mode returns $expected',
({ category, tags, mode, expected }) => {
const filter = filterByCategory(category, mode)
'category=$category with tags=$tags returns $expected',
({ category, tags, expected }) => {
const filter = filterByCategory(category)
const asset = createAsset('model.safetensors', { tags })
expect(filter(asset)).toBe(expected)
}

View File

@@ -1,14 +1,21 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { OwnershipOption } from '@/platform/assets/types/filterTypes'
import {
getAssetBaseModels,
getAssetCategories
} from '@/platform/assets/utils/assetMetadataUtils'
import { getAssetBaseModels } from '@/platform/assets/utils/assetMetadataUtils'
export function filterByCategory(category: string, modelTypeMode: boolean) {
export function filterByCategory(category: string) {
return (asset: AssetItem) => {
if (category === 'all') return true
return getAssetCategories(asset, modelTypeMode).includes(category)
// Check if any tag matches the category (for exact matches)
if (asset.tags.includes(category)) return true
// Check if any tag's top-level folder matches the category
return asset.tags.some((tag) => {
if (typeof tag === 'string' && tag.includes('/')) {
return tag.split('/')[0] === category
}
return false
})
}
}

View File

@@ -2,34 +2,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
MISSING_TAG,
MODELS_TAG
} from '@/platform/assets/services/assetService'
import {
buildModelTypeTagUpdate,
getAssetAdditionalTags,
getAssetBaseModel,
getAssetBaseModels,
getAssetCardTitle,
getAssetCategories,
getAssetDescription,
getAssetDisplayFilename,
getAssetDisplayName,
getAssetFilename,
getAssetMetadataDimensions,
getAssetModelType,
getAssetNodeCategoryCandidates,
getAssetSourceUrl,
getPrimaryCategoryTag,
getAssetStoredFilename,
getAssetTriggerPhrases,
getAssetTypeBadges,
getAssetUserDescription,
getEditableModelType,
getSourceName,
resolveDisplayImageDimensions,
stripModelTypePrefix,
toModelTypeTag
resolveDisplayImageDimensions
} from '@/platform/assets/utils/assetMetadataUtils'
const { isCloudRef } = vi.hoisted(() => ({
@@ -286,17 +274,7 @@ describe('assetMetadataUtils', () => {
tags: ['models'],
expected: null
},
{ name: 'returns null when tags empty', tags: [], expected: null },
{
name: 'never returns a raw model_type: literal (no round-trip into edit widgets)',
tags: ['models', 'model_type:checkpoints'],
expected: null
},
{
name: 'skips model_type: tags in favour of the bare twin',
tags: ['models', 'model_type:checkpoints', 'checkpoints'],
expected: 'checkpoints'
}
{ name: 'returns null when tags empty', tags: [], expected: null }
])('$name', ({ tags, expected }) => {
const asset = { ...mockAsset, tags }
expect(getAssetModelType(asset)).toBe(expected)
@@ -562,353 +540,3 @@ describe('assetMetadataUtils', () => {
})
})
})
describe('getAssetCategories', () => {
const asset = (tags: string[]): AssetItem => ({
id: 'a',
name: 'model.safetensors',
tags
})
it('uses model_type:* values as the group and disregards other tags in model_type mode', () => {
expect(
getAssetCategories(
asset(['models', 'model_type:checkpoints', 'sdxl']),
true
)
).toEqual(['checkpoints'])
})
it('preserves the model_type value casing', () => {
expect(
getAssetCategories(asset(['models', 'model_type:LLM']), true)
).toEqual(['LLM'])
})
it('routes an uncovered asset by its bare tags in model_type mode', () => {
expect(getAssetCategories(asset(['models', 'checkpoints']), true)).toEqual([
'checkpoints'
])
})
it('ignores model_type: and uses bare-tag grouping when mode is off', () => {
expect(
getAssetCategories(
asset(['models', 'model_type:checkpoints', 'sdxl']),
false
)
).toEqual(['model_type:checkpoints', 'sdxl'])
})
it('never surfaces namespace residue as a category for uncovered assets in mode', () => {
expect(
getAssetCategories(asset(['models', 'model_type:', 'sdxl']), true)
).toEqual(['sdxl'])
})
})
describe('getPrimaryCategoryTag', () => {
const asset = (tags: string[]): AssetItem => ({
id: 'a',
name: 'model.safetensors',
tags
})
it('uses the model_type value a covered asset groups under', () => {
expect(
getPrimaryCategoryTag(asset(['models', 'sdxl', 'model_type:vae']), true)
).toBe('vae')
})
it('keeps the legacy verbatim tag for an uncovered hierarchical asset', () => {
expect(
getPrimaryCategoryTag(asset(['models', 'Chatterbox/sub/model']), true)
).toBe('Chatterbox/sub/model')
})
it('skips namespace residue instead of titling off a raw model_type: tag', () => {
expect(
getPrimaryCategoryTag(asset(['models', 'model_type:']), true)
).toBeUndefined()
})
it('returns the legacy first non-models tag when mode is off', () => {
expect(
getPrimaryCategoryTag(asset(['models', 'model_type:vae']), false)
).toBe('model_type:vae')
})
})
describe('getAssetNodeCategoryCandidates', () => {
const asset = (tags: string[]): AssetItem => ({
id: 'a',
name: 'model.safetensors',
tags
})
it('orders the most specific (deepest) tag ahead of a flat model_type value', () => {
expect(
getAssetNodeCategoryCandidates(
asset(['models', 'model_type:LLM', 'LLM/Qwen-VL/Qwen3-0.6B']),
true
)
).toEqual(['LLM/Qwen-VL/Qwen3-0.6B', 'LLM'])
})
it('strips the model_type: prefix when it is the only candidate', () => {
expect(
getAssetNodeCategoryCandidates(asset(['models', 'model_type:vae']), true)
).toEqual(['vae'])
})
it('keeps a model_type value ahead of an equally-deep bare tag', () => {
expect(
getAssetNodeCategoryCandidates(
asset(['models', 'model_type:checkpoints', 'sdxl']),
true
)
).toEqual(['checkpoints', 'sdxl'])
})
it('demotes an unrelated deeper bare tag below the model_type value', () => {
expect(
getAssetNodeCategoryCandidates(
asset(['models', 'model_type:vae', 'foo/bar']),
true
)
).toEqual(['vae', 'foo/bar'])
})
it('keeps unrelated bare tags as trailing fallbacks rather than dropping them', () => {
expect(
getAssetNodeCategoryCandidates(
asset(['models', 'model_type:LLM', 'LLM/Qwen-VL', 'foo/bar/baz']),
true
)
).toEqual(['LLM/Qwen-VL', 'LLM', 'foo/bar/baz'])
})
it('keeps a hierarchical tag intact', () => {
expect(
getAssetNodeCategoryCandidates(
asset(['models', 'chatterbox/chatterbox_vc']),
true
)
).toEqual(['chatterbox/chatterbox_vc'])
})
it('returns no candidates when only reserved tags are present', () => {
expect(
getAssetNodeCategoryCandidates(asset(['models', 'missing']), true)
).toEqual([])
})
it('uses the first non-reserved tag verbatim when mode is off', () => {
expect(
getAssetNodeCategoryCandidates(asset(['models', 'model_type:vae']), false)
).toEqual(['model_type:vae'])
expect(
getAssetNodeCategoryCandidates(asset(['models', 'checkpoints']), false)
).toEqual(['checkpoints'])
})
})
describe('getAssetTypeBadges', () => {
const asset = (tags: string[]): AssetItem => ({
id: 'a',
name: 'model.safetensors',
tags
})
it('strips the model_type: prefix in model_type mode (no raw leak)', () => {
expect(
getAssetTypeBadges(
asset(['models', 'model_type:checkpoints', 'sdxl']),
true
)
).toEqual(['checkpoints'])
})
it('badges the model_type value even when a bare tag comes first, matching the grouping', () => {
expect(
getAssetTypeBadges(asset(['models', 'foo', 'model_type:bar']), true)
).toEqual(['bar'])
})
it('badges every category a shared multi-type asset groups under', () => {
expect(
getAssetTypeBadges(
asset([
'models',
'model_type:checkpoints',
'model_type:diffusion_models'
]),
true
)
).toEqual(['checkpoints', 'diffusion_models'])
})
it('falls back to the bare tag for an uncovered asset in model_type mode', () => {
expect(getAssetTypeBadges(asset(['models', 'sdxl']), true)).toEqual([
'sdxl'
])
})
it('returns no badge rather than a blank one for a malformed empty model_type: tag', () => {
expect(getAssetTypeBadges(asset(['models', 'model_type:']), true)).toEqual(
[]
)
})
it('leaks the literal model_type: tag when mode is off', () => {
expect(
getAssetTypeBadges(asset(['models', 'model_type:checkpoints']), false)
).toEqual(['model_type:checkpoints'])
})
it('shows the segment after the first slash for a bare hierarchical tag', () => {
expect(
getAssetTypeBadges(asset(['models', 'checkpoint/xl']), false)
).toEqual(['xl'])
})
it('returns no badges when only the models tag is present', () => {
expect(getAssetTypeBadges(asset(['models']), true)).toEqual([])
})
})
describe('stripModelTypePrefix', () => {
it('removes the model_type: prefix when present', () => {
expect(stripModelTypePrefix('model_type:checkpoints')).toBe('checkpoints')
})
it('leaves a tag without the prefix unchanged', () => {
expect(stripModelTypePrefix('checkpoints')).toBe('checkpoints')
expect(stripModelTypePrefix('checkpoint/xl')).toBe('checkpoint/xl')
})
})
describe('toModelTypeTag', () => {
it('prefixes a folder_name with the model_type namespace', () => {
expect(toModelTypeTag('checkpoints')).toBe('model_type:checkpoints')
expect(toModelTypeTag('ultralytics_bbox')).toBe(
'model_type:ultralytics_bbox'
)
})
})
describe('getEditableModelType', () => {
const asset = (tags: string[]): AssetItem => ({
id: 'a',
name: 'model.safetensors',
tags
})
it('returns the stripped model_type value in model_type mode', () => {
expect(
getEditableModelType(
asset(['models', 'checkpoints', 'model_type:checkpoints']),
true
)
).toBe('checkpoints')
})
it('falls back to the bare tag for an uncovered asset in model_type mode', () => {
expect(getEditableModelType(asset(['models', 'sam2']), true)).toBe('sam2')
})
it('uses the legacy first-non-models tag when mode is off', () => {
expect(
getEditableModelType(asset(['models', 'checkpoints', 'sdxl']), false)
).toBe('checkpoints')
})
it('returns null when only the models tag is present', () => {
expect(getEditableModelType(asset(['models']), true)).toBeNull()
})
})
describe('buildModelTypeTagUpdate', () => {
const asset = (tags: string[]): AssetItem => ({
id: 'a',
name: 'model.safetensors',
tags
})
it('swaps the bare subtype tag when mode is off', () => {
expect(
buildModelTypeTagUpdate(asset(['models', 'checkpoints']), 'loras', false)
).toEqual(['models', 'loras'])
})
it('preserves user labels and swaps only the subtype tag when mode is off', () => {
expect(
buildModelTypeTagUpdate(
asset(['models', 'checkpoints', 'sdxl']),
'loras',
false
)
).toEqual(['models', 'sdxl', 'loras'])
})
it('writes only the model_type form for a covered asset, leaving the bare twin for the backend', () => {
expect(
buildModelTypeTagUpdate(
asset(['models', 'checkpoints', 'model_type:checkpoints']),
'loras',
true
)
).toEqual(['models', 'checkpoints', 'model_type:loras'])
})
it('replaces every existing model_type form for a shared-path dual-tagged asset', () => {
expect(
buildModelTypeTagUpdate(
asset([
'models',
'diffusion_models',
'model_type:diffusion_models',
'model_type:unet_gguf'
]),
'loras',
true
)
).toEqual(['models', 'diffusion_models', 'model_type:loras'])
})
it('drops the bare current type for an uncovered asset in model_type mode', () => {
expect(
buildModelTypeTagUpdate(asset(['models', 'sam2']), 'loras', true)
).toEqual(['models', 'model_type:loras'])
})
it('keeps user labels untouched in model_type mode', () => {
expect(
buildModelTypeTagUpdate(
asset(['models', 'checkpoints', 'model_type:checkpoints', 'sdxl']),
'loras',
true
)
).toEqual(['models', 'checkpoints', 'sdxl', 'model_type:loras'])
})
})
describe('reserved tag mirrors', () => {
const asset = (tags: string[]): AssetItem => ({
id: 'a',
name: 'model.safetensors',
tags
})
it("treats assetService's canonical reserved tags as reserved (locals must not drift)", () => {
expect(getAssetCategories(asset([MODELS_TAG, 'x']), false)).toEqual(['x'])
expect(
getAssetNodeCategoryCandidates(
asset([MODELS_TAG, MISSING_TAG, 'x']),
true
)
).toEqual(['x'])
expect(getAssetTypeBadges(asset([MODELS_TAG, 'x']), false)).toEqual(['x'])
expect(getAssetModelType(asset([MODELS_TAG]))).toBeNull()
})
})

View File

@@ -2,11 +2,6 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { isCloud } from '@/platform/distribution/types'
import { isCivitaiUrl } from '@/utils/formatUtil'
// Reserved tag literals (mirror assetService's MODELS_TAG/MISSING_TAG). Kept
// local so this leaf util doesn't pull the heavier assetService -> i18n chain.
const MODELS_TAG = 'models'
const MISSING_TAG = 'missing'
/**
* Type-safe utilities for extracting metadata from assets.
* These utilities check user_metadata first, then metadata, then fallback.
@@ -145,236 +140,16 @@ export function getSourceName(url: string): string {
return 'Source'
}
export const MODEL_TYPE_TAG_PREFIX = 'model_type:'
/**
* Extracts the model type from asset tags as a bare (non-namespaced) value.
* Never returns a raw `model_type:*` literal: this value feeds edit widgets
* whose save path writes tags back verbatim, so a namespaced tag leaking
* through here would round-trip the prefixed literal into the tag set.
* Extracts the model type from asset tags
* @param asset - The asset to extract model type from
* @returns The model type string or null if not present
*/
export function getAssetModelType(asset: AssetItem): string | null {
const typeTag = asset.tags?.find(
(tag) => tag && tag !== MODELS_TAG && !tag.startsWith(MODEL_TYPE_TAG_PREFIX)
)
const typeTag = asset.tags?.find((tag) => tag && tag !== 'models')
return typeTag ?? null
}
/**
* Builds the namespaced subtype tag the backend stores in `model_type:` mode.
* The argument is a discovery folder_name (e.g. `checkpoints`,
* `ultralytics_bbox`); the backend keeps the bare directory-path twin in sync.
*/
export function toModelTypeTag(folderName: string): string {
return `${MODEL_TYPE_TAG_PREFIX}${folderName}`
}
/** Strips the `model_type:` prefix off each namespaced tag, dropping non-`model_type:` tags. */
function getModelTypeTagValues(asset: AssetItem): string[] {
return asset.tags
.filter((tag) => tag.startsWith(MODEL_TYPE_TAG_PREFIX))
.map((tag) => tag.slice(MODEL_TYPE_TAG_PREFIX.length))
.filter((tag) => tag.length > 0)
}
/**
* Resolves the folder_name shown as the asset's current model type in the edit
* dropdown. In `modelTypeMode` the stripped `model_type:` value is authoritative
* (covered assets); an uncovered asset with no `model_type:` tag falls back to
* its bare subtype tag, mirroring the read-side grouping. Outside the mode this
* is the legacy first-non-`models` tag.
*/
export function getEditableModelType(
asset: AssetItem,
modelTypeMode: boolean
): string | null {
if (modelTypeMode) {
const [modelType] = getModelTypeTagValues(asset)
if (modelType) return modelType
}
return getAssetModelType(asset)
}
/**
* Computes the tag set for re-typing a model asset to `newFolderName`. In
* `modelTypeMode` only the `model_type:` form is written — the backend keeps the
* bare directory-path twin in sync, so existing `model_type:` tags are dropped
* (covered assets) or the bare current type is dropped (uncovered assets) and
* the new `model_type:<folder_name>` is added. Outside the mode it swaps the
* legacy bare subtype tag, preserving the pre-namespace behavior.
*/
export function buildModelTypeTagUpdate(
asset: AssetItem,
newFolderName: string,
modelTypeMode: boolean
): string[] {
if (!modelTypeMode) {
const currentType = getAssetModelType(asset)
return asset.tags.filter((tag) => tag !== currentType).concat(newFolderName)
}
const modelTypeTags = asset.tags.filter((tag) =>
tag.startsWith(MODEL_TYPE_TAG_PREFIX)
)
const currentBareType = getAssetModelType(asset)
const tagsToRemove =
modelTypeTags.length > 0
? new Set(modelTypeTags)
: new Set(currentBareType ? [currentBareType] : [])
return asset.tags
.filter((tag) => !tagsToRemove.has(tag))
.concat(toModelTypeTag(newFolderName))
}
/** Legacy grouping: each non-`models` tag's top-level path segment. */
function getBareTagCategories(asset: AssetItem): string[] {
return asset.tags
.filter((tag) => tag !== MODELS_TAG && tag.length > 0)
.map((tag) => tag.split('/')[0])
}
/**
* Resolves the category keys a model asset is grouped under.
*
* `modelTypeMode` reflects whether the backend declares the `model_type:` tag
* scheme (the `supports_model_type_tags` capability). When true, an asset's
* `model_type:*` values are authoritative; an asset with no `model_type:` tag
* still routes by its bare tags. When false (the default) categories come from
* the legacy bare-tag top-level grouping and `model_type:` is ignored.
*/
export function getAssetCategories(
asset: AssetItem,
modelTypeMode: boolean
): string[] {
if (modelTypeMode) {
const modelTypes = getModelTypeTagValues(asset)
if (modelTypes.length > 0) return modelTypes
// Uncovered assets route by bare tags, but namespace residue (e.g. a
// malformed empty `model_type:`) must not surface as a raw category.
return getBareTagCategories(asset).filter(
(category) => !category.startsWith(MODEL_TYPE_TAG_PREFIX)
)
}
return getBareTagCategories(asset)
}
/**
* Resolves the primary tag a browser surface titles itself after. In
* `modelTypeMode` a covered asset uses its first `model_type:*` value — the
* key it groups under — while an uncovered asset keeps the legacy selection
* (first non-`models` tag, verbatim, hierarchical paths intact). Outside the
* mode this is exactly the legacy selection.
*/
export function getPrimaryCategoryTag(
asset: AssetItem,
modelTypeMode: boolean
): string | undefined {
if (modelTypeMode) {
const [modelType] = getModelTypeTagValues(asset)
if (modelType) return modelType
return asset.tags.find(
(tag) => tag !== MODELS_TAG && !tag.startsWith(MODEL_TYPE_TAG_PREFIX)
)
}
return asset.tags.find((tag) => tag !== MODELS_TAG)
}
/** Number of `parent/child` segments in a tag, used to pick the most specific. */
function pathDepth(tag: string): number {
return tag.split('/').length
}
/** Removes the `model_type:` namespace prefix from a tag when present. */
export function stripModelTypePrefix(tag: string): string {
return tag.startsWith(MODEL_TYPE_TAG_PREFIX)
? tag.slice(MODEL_TYPE_TAG_PREFIX.length)
: tag
}
/**
* Resolves the labels shown as an asset card's type badges.
*
* In `modelTypeMode` a covered asset badges every `model_type:*` value — the
* same keys it groups under (`getAssetCategories`) — so a shared-root asset
* tagged with several categories carries each of them; whichever category
* view the card appears in is represented on the card. Uncovered assets (and
* legacy mode) keep the original single selection: first non-`models` tag,
* with bare hierarchical tags showing the segment after the first `/`.
*/
export function getAssetTypeBadges(
asset: AssetItem,
modelTypeMode: boolean
): string[] {
if (modelTypeMode) {
const modelTypes = getModelTypeTagValues(asset)
if (modelTypes.length > 0) return modelTypes
}
const typeTag = asset.tags.find(
(tag) =>
tag !== MODELS_TAG &&
!(modelTypeMode && tag.startsWith(MODEL_TYPE_TAG_PREFIX))
)
if (!typeTag) return []
return [
typeTag.includes('/') ? typeTag.slice(typeTag.indexOf('/') + 1) : typeTag
]
}
/**
* Ordered node-category candidates for an asset, most specific first.
*
* Callers resolve a node provider by trying each candidate in order and taking
* the first that maps to a provider. The full (possibly hierarchical) value is
* kept so `modelToNodeStore`'s `parent/child` fallback still works.
*
* In `modelTypeMode` (backend declares `supports_model_type_tags`) candidates
* come in two tiers. Tier 1: the stripped `model_type:*` values plus bare tags
* *related* to one of them (equal to it, or extending it as a `parent/child`
* path), ordered by descending depth — so a resolvable `LLM/Qwen-VL/...` twin
* wins over a flat `model_type:LLM`, while ties keep `model_type:*` values
* ahead of bare tags. Tier 2: unrelated bare tags (e.g. a user-added
* `foo/bar`), tried only when nothing authoritative resolves — they can no
* longer pre-empt a resolvable `model_type:*` value however deep they are.
* An uncovered asset (no `model_type:` tag) routes by all its bare tags,
* deepest first. Outside `modelTypeMode` the legacy first-non-reserved tag is
* used verbatim.
*/
export function getAssetNodeCategoryCandidates(
asset: AssetItem,
modelTypeMode: boolean
): string[] {
if (!modelTypeMode) {
const legacy = asset.tags.find(
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
)
return legacy ? [legacy] : []
}
const bareTags = asset.tags.filter(
(tag) =>
tag !== MODELS_TAG &&
tag !== MISSING_TAG &&
!tag.startsWith(MODEL_TYPE_TAG_PREFIX)
)
const byDepthDesc = (a: string, b: string) => pathDepth(b) - pathDepth(a)
const modelTypes = getModelTypeTagValues(asset)
if (modelTypes.length === 0) return bareTags.toSorted(byDepthDesc)
const isRelated = (tag: string) =>
modelTypes.some((type) => tag === type || tag.startsWith(`${type}/`))
return [
...[...modelTypes, ...bareTags.filter(isRelated)].sort(byDepthDesc),
...bareTags.filter((tag) => !isRelated(tag)).sort(byDepthDesc)
]
}
/**
* Extracts user description from asset user_metadata
* @param asset - The asset to extract user description from

View File

@@ -1,25 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
const mockGetNodeProvider = vi.hoisted(() => vi.fn())
const mockSupportsModelTypeTags = vi.hoisted(() => ({ value: false }))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: {
get supportsModelTypeTags() {
return mockSupportsModelTypeTags.value
}
}
})
}))
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-123',
@@ -60,11 +49,6 @@ describe('resolveModelNodeFromAsset', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
mockSupportsModelTypeTags.value = false
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('valid assets', () => {
@@ -84,48 +68,6 @@ describe('resolveModelNodeFromAsset', () => {
}
})
it('strips the model_type: prefix when resolving the provider in model_type mode', () => {
mockSupportsModelTypeTags.value = true
mockProvider(createMockNodeProvider())
const result = resolveModelNodeFromAsset(
createMockAsset({ tags: ['models', 'model_type:vae'] })
)
expect(result.success).toBe(true)
expect(mockGetNodeProvider).toHaveBeenCalledWith('vae')
})
it('skips an unresolvable incidental tag and resolves via the model_type value', () => {
mockSupportsModelTypeTags.value = true
mockGetNodeProvider.mockImplementation((category: string) =>
category === 'vae' ? createMockNodeProvider() : undefined
)
const result = resolveModelNodeFromAsset(
createMockAsset({ tags: ['models', 'model_type:vae', 'foo/bar'] })
)
expect(result.success).toBe(true)
expect(mockGetNodeProvider).toHaveBeenCalledWith('foo/bar')
expect(mockGetNodeProvider).toHaveBeenCalledWith('vae')
})
it('prefers the deepest resolvable path over a flat model_type value', () => {
mockSupportsModelTypeTags.value = true
mockGetNodeProvider.mockImplementation((category: string) =>
category === 'LLM/Qwen-VL/Qwen3-0.6B'
? createMockNodeProvider()
: undefined
)
const result = resolveModelNodeFromAsset(
createMockAsset({
tags: ['models', 'model_type:LLM', 'LLM/Qwen-VL/Qwen3-0.6B']
})
)
expect(result.success).toBe(true)
expect(mockGetNodeProvider).toHaveBeenCalledWith('LLM/Qwen-VL/Qwen3-0.6B')
})
it('falls back to metadata.filename when user_metadata.filename missing', () => {
mockProvider(createMockNodeProvider())
const result = resolveModelNodeFromAsset(
@@ -259,7 +201,7 @@ describe('resolveModelNodeFromAsset', () => {
if (!result.success) {
expect(result.error.code).toBe('NO_PROVIDER')
expect(result.error.message).toContain('checkpoints')
expect(result.error.details?.candidates).toEqual(['checkpoints'])
expect(result.error.details?.category).toBe('checkpoints')
}
})
})

View File

@@ -4,11 +4,7 @@ import {
MISSING_TAG,
MODELS_TAG
} from '@/platform/assets/services/assetService'
import {
getAssetFilename,
getAssetNodeCategoryCandidates
} from '@/platform/assets/utils/assetMetadataUtils'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
@@ -85,12 +81,10 @@ export function resolveModelNodeFromAsset(
}
}
const { flags } = useFeatureFlags()
const candidates = getAssetNodeCategoryCandidates(
validAsset,
flags.supportsModelTypeTags
const category = validAsset.tags.find(
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
)
if (candidates.length === 0) {
if (!category) {
console.error(
`Asset ${validAsset.id} has no valid category tag. Available tags: ${validAsset.tags.join(', ')} (expected tag other than '${MODELS_TAG}' or '${MISSING_TAG}')`
)
@@ -105,31 +99,19 @@ export function resolveModelNodeFromAsset(
}
}
const modelToNodeStore = useModelToNodeStore()
const resolved = candidates
.map((category) => ({
category,
provider: modelToNodeStore.getNodeProvider(category)
}))
.find((candidate) => candidate.provider !== undefined)
if (!resolved?.provider) {
// Known gap (out of scope for FE-1076): flat `model_type:LLM`-style tags
// whose loaders are only registered hierarchically land here until the
// backend emits a subtype-carrying tag.
console.error(
`No node provider registered for category: ${candidates.join(', ')}`
)
const provider = useModelToNodeStore().getNodeProvider(category)
if (!provider) {
console.error(`No node provider registered for category: ${category}`)
return {
success: false,
error: {
code: 'NO_PROVIDER',
message: `No node provider registered for category: ${candidates.join(', ')}`,
message: `No node provider registered for category: ${category}`,
assetId: validAsset.id,
details: { candidates }
details: { category }
}
}
}
return { success: true, value: { provider: resolved.provider, filename } }
return { success: true, value: { provider, filename } }
}

View File

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

View File

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

View File

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

View File

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

@@ -233,5 +233,12 @@ 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')
})
})
})

View File

@@ -27,13 +27,14 @@ 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]',
PlanCredits: 'icon-[lucide--credit-card]',
PlanCredits: 'icon-[lucide--receipt-text]',
secrets: 'icon-[lucide--key-round]',
'server-config': 'icon-[lucide--server]',
subscription: 'icon-[lucide--credit-card]',
user: 'icon-[lucide--user]',
workspace: 'icon-[lucide--building-2]'
workspace: 'icon-[lucide--receipt-text]'
}
interface SettingPanelItem {
@@ -175,16 +176,30 @@ export function useSettingUI(
)
}
// Workspace panel: only available on cloud with team workspaces enabled
const workspacePanel: SettingPanelItem = {
// 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 = {
node: {
key: 'workspace',
label: 'Workspace',
label: 'PlanCredits',
children: []
},
component: defineAsyncComponent(
() =>
import('@/platform/workspace/components/dialogs/settings/WorkspacePanelContent.vue')
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')
)
}
@@ -245,7 +260,9 @@ export function useSettingUI(
aboutPanel,
creditsPanel,
userPanel,
...(shouldShowWorkspacePanel.value ? [workspacePanel] : []),
...(shouldShowWorkspacePanel.value
? [planCreditsPanel, membersPanel]
: []),
keybindingPanel,
extensionPanel,
...(isDesktop ? [serverConfigPanel] : []),
@@ -295,8 +312,13 @@ export function useSettingUI(
key: 'workspace',
label: 'Workspace',
children: [
...(shouldShowWorkspacePanel.value ? [workspacePanel.node] : []),
...(isLoggedIn.value &&
...(shouldShowWorkspacePanel.value
? [planCreditsPanel.node, membersPanel.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 &&
!(isCloud && window.__CONFIG__?.subscription_required)
? [creditsPanel.node]
: [])

View File

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

@@ -1215,15 +1215,6 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: isCloud ? true : false,
experimental: true
},
{
id: 'Comfy.ModelLibrary.UseAssetBrowser',
name: 'Use the asset browser for the model library',
type: 'hidden',
tooltip:
'When enabled alongside the asset API, the model library opens the asset browser. Otherwise it opens the sidebar tree.',
defaultValue: isCloud ? true : false,
experimental: true
},
{
id: 'Comfy.VersionCompatibility.DisableWarnings',
name: 'Disable version compatibility warnings',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,313 @@
<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
for="auto-reload-threshold"
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
id="auto-reload-threshold"
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 for="auto-reload-amount" 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
id="auto-reload-amount"
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
id="auto-reload-budget-label"
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"
aria-labelledby="auto-reload-budget-label"
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) =>
fmtNumber(cents / 100, {
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 owner', async () => {
it('shows promote copy and confirms with Make admin', async () => {
const { user } = renderDialog('owner')
expect(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,205 @@
<template>
<div
:class="
cn(
'flex flex-col gap-4 rounded-2xl border border-interface-stroke/60 p-6 transition-opacity',
// A lapsed plan can't auto-reload, so freeze the whole section: dim it,
// block interaction, and force the toggle to read Disabled.
frozen && 'pointer-events-none opacity-50'
)
"
>
<div
class="flex flex-col gap-4 @4xl:flex-row @4xl:items-start @4xl:justify-between"
>
<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="displayEnabled"
@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-1 gap-4 @4xl:grid-cols-2">
<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-1 gap-4 @4xl:grid-cols-2">
<div
:class="
cn(
'flex flex-col gap-4 rounded-xl bg-modal-panel-background px-6 py-5 transition-opacity',
// Skip this dim when frozen — the section root already dims uniformly.
!frozen && !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="badgeSeverity" />
</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-2xl leading-none 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>
<template v-if="hasBudget">
<div class="h-px w-full bg-interface-stroke" />
<div 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="cn('tabular-nums', percentSpentClass)">
{{ percentSpentLabel }}
</span>
</div>
<ProgressBar :value="budgetUsedFraction" />
<div class="flex justify-end text-sm">
<span class="text-muted-foreground tabular-nums">
{{ budgetSpentLabel }}
</span>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
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 { frozen = false } = defineProps<{
/**
* The plan can't spend (lapsed or paused): render the whole section dimmed,
* off, and non-interactive.
*/
frozen?: boolean
}>()
const { t, n: fmtNumber } = useI18n()
const {
config,
isConfigured,
isEnabled,
hasBudget,
budgetUsedFraction,
isPaused,
isWarning,
setEnabled
} = useAutoReload()
const displayEnabled = computed(() => !frozen && isEnabled.value)
const { showAutoReloadDialog } = useDialogService()
function openConfig() {
void showAutoReloadDialog()
}
const fmtCredits = (value: number) =>
fmtNumber(value, { maximumFractionDigits: 0 })
const fmtUsd = (cents: number) =>
fmtNumber(cents / 100, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
})
const enabledLabel = computed(() =>
displayEnabled.value
? t('workspacePanel.autoReload.enabled')
: t('workspacePanel.autoReload.disabled')
)
const badge = computed(() => {
if (frozen) return t('workspacePanel.autoReload.badge.off')
if (isPaused.value) return t('workspacePanel.autoReload.badge.paused')
if (!isEnabled.value) return t('workspacePanel.autoReload.badge.off')
return ''
})
// Paused is an alert state, so give it the high-contrast (inverted) pill; the
// quieter "off" state keeps the secondary treatment.
const badgeSeverity = computed(() =>
isPaused.value ? 'contrast' : 'secondary'
)
const reloadCreditsLabel = computed(() => fmtCredits(config.reloadCredits))
const thresholdLabel = computed(() => fmtCredits(config.thresholdCredits))
const percentSpentLabel = computed(() =>
t('workspacePanel.autoReload.tile.percentSpent', {
percent: Math.round(budgetUsedFraction.value * 100)
})
)
const percentSpentClass = computed(() =>
isPaused.value
? 'text-danger'
: isWarning.value
? 'text-credit'
: 'text-muted-foreground'
)
const budgetSpentLabel = computed(() =>
t('workspacePanel.autoReload.tile.spentOfBudget', {
spent: fmtUsd(config.spentThisCycleCents),
budget: fmtUsd(config.monthlyBudgetCents ?? 0)
})
)
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

@@ -0,0 +1,107 @@
<template>
<div
:class="
cn(
'@container 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-6'
)
"
>
<div
class="flex w-full flex-col gap-3 @2xl:flex-row @2xl:items-center @2xl: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 === 'activity'"
v-model="searchQuery"
:placeholder="$t('g.search')"
size="lg"
class="w-full @2xl:w-64"
/>
</div>
<BillingStatusBanner>
<template #actions>
<Button
v-if="activeView === 'invoices' && isPaused"
variant="textonly"
size="lg"
@click="openInvoiceHistory"
>
{{ $t('workspacePanel.invoices.fullHistory') }}
<i class="icon-[lucide--external-link] size-4" />
</Button>
</template>
</BillingStatusBanner>
<WorkspaceOverviewContent
v-if="activeView === 'overview'"
@navigate="setView"
/>
<WorkspaceActivityContent
v-else-if="activeView === 'activity'"
:search="searchQuery"
/>
<WorkspaceInvoicesContent v-else />
</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 { useBillingContext } from '@/composables/billing/useBillingContext'
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 { isPaused, manageSubscription } = useBillingContext()
function openInvoiceHistory() {
void manageSubscription()
}
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

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

@@ -0,0 +1,315 @@
<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" :aria-sort="ariaSort('date')">
<button :class="sortHeaderClass" @click="toggleSort('date')">
{{ $t('workspacePanel.activity.columns.date') }}
<i :class="sortIcon('date')" />
</button>
</TableHead>
<TableHead class="w-56" :aria-sort="ariaSort('user')">
<button :class="sortHeaderClass" @click="toggleSort('user')">
{{ $t('workspacePanel.activity.columns.user') }}
<i :class="sortIcon('user')" />
</button>
</TableHead>
<TableHead :aria-sort="ariaSort('eventType')">
<button :class="sortHeaderClass" @click="toggleSort('eventType')">
{{ $t('workspacePanel.activity.columns.eventType') }}
<i :class="sortIcon('eventType')" />
</button>
</TableHead>
<TableHead class="w-32" :aria-sort="ariaSort('detail')">
<button :class="sortHeaderClass" @click="toggleSort('detail')">
{{ $t('workspacePanel.activity.columns.eventDetails') }}
<i :class="sortIcon('detail')" />
</button>
</TableHead>
<TableHead class="w-40" :aria-sort="ariaSort('credits')">
<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 [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ formatDate(event.date) }}
</TableCell>
<TableCell>
<HoverCard
v-if="event.userName"
: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" align="start">
<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>
<span v-else class="text-sm text-muted-foreground"></span>
</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" align="start">
<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="
cn(
'text-right text-sm tabular-nums',
event.credited ? 'text-credit' : 'text-muted-foreground'
)
"
>
{{ event.credited ? '+' : ''
}}{{ event.credits.toLocaleString() }}
</TableCell>
</TableRow>
<TableRow v-if="pagedItems.length === 0" class="hover:bg-transparent">
<TableCell
:colspan="5"
class="py-6 text-center text-sm text-muted-foreground"
>
{{ $t('workspacePanel.activity.empty') }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div class="flex flex-col gap-3 @2xl:h-8 @2xl:flex-row @2xl:items-center">
<div
v-if="canViewTeamUsage"
class="flex flex-wrap items-center gap-x-6 gap-y-2"
>
<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="@2xl: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, 1)
// 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 ariaSort(
field: ActivitySortField
): 'ascending' | 'descending' | 'none' {
if (sortField.value !== field) return 'none'
return sortDirection.value === 'asc' ? 'ascending' : 'descending'
}
function formatDate(date: Date): string {
return d(date, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
})
}
</script>

View File

@@ -0,0 +1,58 @@
<template>
<div class="flex min-h-0 flex-1 flex-col gap-4">
<!-- Invoice history and downloads live in Stripe, so this tab just surfaces
the upcoming charge and links out; the local history table is hidden
for now. When paused there's no upcoming invoice — the parent's
"Subscription paused" banner covers that state and hosts the history
link, so we drop this banner to avoid stacking two. Hidden until the
API can supply the upcoming amount. -->
<div
v-if="!isPaused && nextInvoiceCents !== null"
class="flex flex-col gap-4 rounded-2xl border border-interface-stroke/60 p-4 @2xl:flex-row @2xl:items-center @2xl:justify-between"
>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-foreground">
{{ $t('workspacePanel.overview.nextInvoice') }}
</span>
<p class="m-0 text-2xl font-semibold text-base-foreground">
{{ formatWholeUsd(nextInvoiceCents) }}
<span class="text-base font-normal text-base-foreground">
{{ $t('workspacePanel.overview.usd') }}
</span>
</p>
</div>
<Button variant="secondary" size="lg" @click="openHistory">
{{ $t('workspacePanel.invoices.fullHistory') }}
<i class="icon-[lucide--external-link] size-4" />
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useWorkspaceInvoices } from '@/platform/workspace/composables/useWorkspaceInvoices'
const { n } = useI18n()
const { isPaused, manageSubscription } = useBillingContext()
const { nextInvoiceCents } = useWorkspaceInvoices()
function formatWholeUsd(cents: number | null): string {
if (cents === null) return ''
return n(cents / 100, {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0
})
}
function openHistory() {
void manageSubscription()
}
</script>

View File

@@ -0,0 +1,21 @@
<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(() => {
void fetchMembers()
void fetchPendingInvites()
})
</script>

View File

@@ -5,6 +5,7 @@ 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'
@@ -75,6 +76,25 @@ 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,10 +1,16 @@
<template>
<DropdownMenu :entries="menuItems">
<DropdownMenu
v-if="menuItems.length > 0"
:entries="menuItems"
:modal="false"
@close-auto-focus="onMenuCloseAutoFocus"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="muted-textonly"
variant="secondary"
size="icon-lg"
class="rounded-lg"
:aria-label="$t('g.moreOptions')"
>
<i class="pi pi-ellipsis-h" />
@@ -21,20 +27,35 @@ 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,
showEditWorkspaceDialog
} = useDialogService()
const { showLeaveWorkspaceDialog, showDeleteWorkspaceDialog } =
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.
@@ -51,22 +72,21 @@ const deleteTooltip = computed(() => {
})
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = []
if (uiConfig.value.showEditWorkspaceMenuItem) {
items.push({
label: t('workspacePanel.menu.editWorkspace'),
icon: 'pi pi-pencil',
command: () => showEditWorkspaceDialog()
})
}
const renameItems: MenuItem[] = uiConfig.value.showEditWorkspaceMenuItem
? [
{
label: t('workspacePanel.menu.renameWorkspace'),
command: beginRename
}
]
: []
const destructiveItems: MenuItem[] = []
const action = uiConfig.value.workspaceMenuAction
if (action === 'delete') {
items.push({
destructiveItems.push({
label: t('workspacePanel.menu.deleteWorkspace'),
icon: 'pi pi-trash',
class: isDeleteDisabled.value ? 'text-danger/50' : 'text-danger',
class: isDeleteDisabled.value ? undefined : 'text-danger',
disabled: isDeleteDisabled.value,
tooltip: deleteTooltip.value,
command: isDeleteDisabled.value
@@ -77,23 +97,23 @@ const menuItems = computed<MenuItem[]>(() => {
// Members and non-creator owners can leave; the creator sees it disabled.
if (action === 'leave' || action === 'delete') {
items.push(
destructiveItems.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()
}
)
}
return items
const divider: MenuItem[] =
renameItems.length && destructiveItems.length ? [{ separator: true }] : []
return [...renameItems, ...divider, ...destructiveItems]
})
</script>

View File

@@ -0,0 +1,397 @@
<template>
<div
class="flex min-h-0 scrollbar-hide flex-1 flex-col gap-4 overflow-y-auto pb-6"
>
<!-- Plan + credits + member snapshot -->
<div
class="flex flex-col gap-6 rounded-2xl border border-interface-stroke/60 p-6"
>
<!-- Lapsed team/enterprise plan: reactivation header replaces the live one -->
<div
v-if="isInactive"
class="flex flex-col gap-4 @4xl:flex-row @4xl:items-start @4xl:justify-between"
>
<div class="flex flex-col gap-1">
<span class="text-sm text-base-foreground">
{{ inactiveTitle }}
</span>
<span class="text-sm text-muted-foreground">
{{ inactiveSubtitle }}
</span>
</div>
<div v-if="canManageBilling" class="flex shrink-0 items-center gap-2">
<Button variant="secondary" size="lg" @click="manageSubscription">
{{ $t('workspacePanel.overview.managePayment') }}
</Button>
<Button
variant="inverted"
size="lg"
:loading="isResubscribing"
@click="handleResubscribe"
>
{{ $t('workspacePanel.overview.inactive.reactivate') }}
</Button>
</div>
</div>
<div
v-else
class="flex flex-col gap-4 @4xl:flex-row @4xl:items-start @4xl:justify-between"
>
<div class="flex flex-col gap-1">
<span class="text-sm text-base-foreground">{{ plan.name }}</span>
<p
v-if="canManageBilling"
class="m-0 flex items-center gap-1.5 text-base font-semibold whitespace-nowrap text-base-foreground"
>
<i class="icon-[lucide--coins] size-4 text-credit" />
{{ plan.monthlyCredits.toLocaleString() }}
<span class="text-base font-normal text-muted-foreground">
/ {{ $t('workspacePanel.overview.perMonth') }}
</span>
</p>
<span class="text-sm text-muted-foreground">
{{
isPaused
? $t('workspacePanel.overview.paused')
: $t('workspacePanel.overview.renewsOn', {
date: plan.renewalLabel
})
}}
</span>
</div>
<div v-if="canManageBilling" class="flex shrink-0 items-center gap-2">
<Button variant="secondary" size="lg" @click="manageSubscription">
{{ $t('workspacePanel.overview.managePayment') }}
</Button>
<Button
v-if="isOriginalOwner"
variant="secondary"
size="lg"
@click="handleChangePlan"
>
{{ $t('workspacePanel.overview.changePlan') }}
</Button>
<DropdownMenu
v-if="planMenuEntries.length > 0"
:entries="planMenuEntries"
:modal="false"
>
<template #button>
<Button
v-tooltip="{ value: $t('g.moreOptions'), showDelay: 300 }"
variant="secondary"
size="icon-lg"
class="rounded-lg"
:aria-label="$t('g.moreOptions')"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</DropdownMenu>
</div>
</div>
<div class="grid grid-cols-1 gap-4 @4xl:grid-cols-2">
<CreditsTile class="border-0" :frozen="isInactive" />
<!-- Member snapshot tile (hidden while the plan is lapsed) -->
<div
v-if="canManageBilling && !isInactive"
class="flex flex-col gap-3 rounded-xl bg-modal-panel-background px-6 py-5"
>
<Tabs v-model="snapshotView">
<TabsList>
<TabsTrigger value="top">
{{ $t('workspacePanel.overview.snapshot.topSpenders') }}
</TabsTrigger>
<TabsTrigger value="recent">
{{ $t('workspacePanel.overview.snapshot.recentActivity') }}
</TabsTrigger>
</TabsList>
</Tabs>
<div
v-if="snapshotEmptyMessage"
class="flex flex-1 flex-col items-center justify-center gap-3 py-6 text-center"
>
<i class="icon-[lucide--coins] size-6 text-muted-foreground" />
<p class="m-0 text-sm text-base-foreground">
{{ snapshotEmptyMessage }}
</p>
</div>
<div
v-else
ref="snapshotContainer"
class="min-h-0 flex-1 overflow-hidden"
>
<Table>
<TableHeader>
<TableRow
class="hover:bg-transparent [&>th]:h-9 [&>th]:border-b [&>th]:border-interface-stroke/60"
>
<TableHead>
{{ $t('workspacePanel.overview.snapshot.user') }}
</TableHead>
<TableHead>
{{ $t('workspacePanel.overview.snapshot.lastActivity') }}
</TableHead>
<TableHead class="text-right">
<span class="inline-flex items-center gap-1">
<i class="icon-[lucide--coins] size-4" />
{{ $t('workspacePanel.overview.snapshot.creditsUsed') }}
</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow
v-for="row in snapshotRows"
:key="row.userName"
class="hover:bg-transparent [&:last-child>td]:border-b-0 [&>td]:border-b [&>td]:border-interface-stroke/20"
>
<TableCell>
<div class="flex items-center gap-2">
<span
class="flex size-5 shrink-0 items-center justify-center rounded-full"
:style="{ backgroundColor: row.color }"
>
<span class="text-2xs font-bold text-base-foreground">
{{ row.userName.charAt(0).toUpperCase() }}
</span>
</span>
<span class="text-sm text-base-foreground">
{{ row.userName }}
</span>
</div>
</TableCell>
<TableCell class="text-sm text-muted-foreground tabular-nums">
{{ row.lastActivity }}
</TableCell>
<TableCell
class="text-right text-sm text-base-foreground tabular-nums"
>
{{ row.credits.toLocaleString() }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<Button
variant="tertiary"
size="lg"
class="mt-auto w-full"
@click="handleSeeMore"
>
{{ $t('workspacePanel.overview.seeMore') }}
</Button>
</div>
</div>
</div>
<!-- Credit auto-reload; frozen alongside the tile whenever the plan can't
spend — lapsed or paused. -->
<AutoReloadSection
v-if="canManageBilling"
:frozen="isInactive || isPaused"
/>
<!-- mt-auto floats the footer to the panel's bottom edge; pb-6 (matching
the other tabs) keeps it level with their footers. -->
<div
class="mt-auto flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground @2xl:h-8"
>
<a
:href="learnMoreUrl"
target="_blank"
rel="noopener noreferrer"
class="flex cursor-pointer items-center gap-1 text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
<i class="icon-[lucide--circle-help] size-4" />
{{ $t('workspacePanel.overview.learnMore') }}
</a>
<a
:href="partnerNodesPricingUrl"
target="_blank"
rel="noopener noreferrer"
class="flex cursor-pointer items-center gap-1 text-muted-foreground no-underline transition-colors hover:text-base-foreground"
>
<i class="icon-[lucide--circle-help] size-4" />
{{ $t('workspacePanel.overview.pricingTable') }}
</a>
<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="openSupport"
>
<i class="icon-[lucide--message-circle] size-4" />
{{ $t('workspacePanel.overview.messageSupport') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { MenuItem } from 'primevue/menuitem'
import DropdownMenu from '@/components/common/DropdownMenu.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 Tabs from '@/components/ui/tabs/Tabs.vue'
import TabsList from '@/components/ui/tabs/TabsList.vue'
import TabsTrigger from '@/components/ui/tabs/TabsTrigger.vue'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useExternalLink } from '@/composables/useExternalLink'
import { useSettingsNavigation } from '@/platform/settings/composables/useSettingsNavigation'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
import AutoReloadSection from '@/platform/workspace/components/dialogs/settings/AutoReloadSection.vue'
import { buildSupportUrl } from '@/platform/support/config'
import { useAutoPageSize } from '@/platform/workspace/composables/useAutoPageSize'
import { requestMembersSort } from '@/platform/workspace/composables/useMembersPanel'
import { useResubscribe } from '@/platform/workspace/composables/useResubscribe'
import { useTeamPlan } from '@/platform/workspace/composables/useTeamPlan'
import { useWorkspaceOverview } from '@/platform/workspace/composables/useWorkspaceOverview'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useDialogService } from '@/services/dialogService'
const emit = defineEmits<{ navigate: [view: 'activity'] }>()
const { t } = useI18n()
// Plan lifecycle actions are for the workspace creator (Owner) only; Admins and
// Members don't see Change plan or the overflow menu.
const {
isOriginalOwner,
isActiveSubscription,
isTeamPlanCancelled,
permissions
} = useWorkspaceUI()
// Members can't manage or view billing details — only the credit balance. Gates
// the plan price, payment/plan actions, snapshot, next invoice, and auto-reload.
const canManageBilling = computed(() => permissions.value.canManageSubscription)
const {
isFreeTier,
isPaused,
subscription,
manageSubscription,
showSubscriptionDialog
} = useBillingContext()
const { showCancelSubscriptionDialog } = useDialogService()
const { showPricingTable } = useSubscriptionDialog()
function handleChangePlan() {
if (isFreeTier.value) showPricingTable({ reason: 'settings_billing_panel' })
else showSubscriptionDialog({ reason: 'settings_billing_panel' })
}
// A team (or enterprise) workspace whose plan has lapsed shows the reactivation
// state in place of the live plan header, snapshot, and auto-reload.
const { hasLapsedTeamPlan: isInactive } = useTeamPlan()
const { isResubscribing, handleResubscribe } = useResubscribe()
const { buildDocsUrl, docsPaths } = useExternalLink()
const learnMoreUrl = buildDocsUrl('/get_started/cloud', {
includeLocale: true
})
const partnerNodesPricingUrl = buildDocsUrl(docsPaths.partnerNodesPricing, {
includeLocale: true
})
const { userEmail, resolvedUserInfo } = useCurrentUser()
function openSupport() {
const url = buildSupportUrl({
userEmail: userEmail.value,
userId: resolvedUserInfo.value?.id
})
window.open(url, '_blank', 'noopener,noreferrer')
}
const canCancelPlan = computed(
() =>
isOriginalOwner.value &&
isActiveSubscription.value &&
!isTeamPlanCancelled.value &&
!isFreeTier.value
)
const planMenuEntries = computed<MenuItem[]>(() =>
canCancelPlan.value
? [
{
label: t('subscription.cancelPlan'),
command: () =>
void showCancelSubscriptionDialog(
subscription.value?.endDate ?? undefined
)
}
]
: []
)
const { plan, topSpenders, recentActivity } = useWorkspaceOverview()
// Enterprise workspaces read "enterprise" in the lapsed copy, not "team".
const inactiveTitle = computed(() =>
plan.value.name === 'Enterprise'
? t('workspacePanel.overview.inactive.titleEnterprise')
: t('workspacePanel.overview.inactive.title')
)
const inactiveSubtitle = computed(() =>
plan.value.name === 'Enterprise'
? t('workspacePanel.overview.inactive.subtitleEnterprise')
: t('workspacePanel.overview.inactive.subtitle')
)
const { navigateToPanel } = useSettingsNavigation()
// The credits tile dictates the row height; the snapshot tile fits as many
// user rows as that height allows, keeping "See more" pinned to the bottom.
const snapshotContainer = ref<HTMLElement | null>(null)
const { pageSize: visibleSnapshotRows } = useAutoPageSize(snapshotContainer, 1)
const snapshotView = ref('top')
const snapshotRows = computed(() =>
(snapshotView.value === 'top'
? topSpenders.value
: recentActivity.value
).slice(0, visibleSnapshotRows.value)
)
// Each tab gets its own empty state: a top-spenders leaderboard of all-zeros
// (a fresh billing cycle) is meaningless, and recent activity can simply have
// no events yet. Returns the message to show, or null when the table has rows.
const snapshotEmptyMessage = computed(() => {
if (snapshotView.value === 'top') {
return topSpenders.value.some((row) => row.credits > 0)
? null
: t('workspacePanel.overview.snapshot.empty.topSpenders')
}
return recentActivity.value.length === 0
? t('workspacePanel.overview.snapshot.empty.recentActivity')
: null
})
// Top spenders → the Members panel pre-sorted by credit usage; Recent activity
// → the Activity tab.
function handleSeeMore() {
if (snapshotView.value === 'recent') {
emit('navigate', 'activity')
return
}
requestMembersSort('credits')
navigateToPanel('workspace-members')
}
</script>

View File

@@ -1,129 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { WorkspaceMember } from '@/platform/workspace/stores/teamWorkspaceStore'
import WorkspacePanelContent from './WorkspacePanelContent.vue'
const mockFetchMembers = vi.fn()
const mockFetchPendingInvites = vi.fn()
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
mockMembers: ref<WorkspaceMember[]>([]),
mockWorkspaceType: ref<'personal' | 'team'>('team')
}
})
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal()
return {
...(actual as object),
storeToRefs: (store: Record<string, unknown>) => store
}
})
vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
useTeamWorkspaceStore: () => ({
workspaceName: ref('Acme Team'),
members: mockMembers,
fetchMembers: mockFetchMembers,
fetchPendingInvites: mockFetchPendingInvites
})
}
})
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
const { ref } = require('vue') as typeof import('vue')
return {
useWorkspaceUI: () => ({
workspaceType: mockWorkspaceType,
workspaceRole: ref('owner')
})
}
})
vi.mock(
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
() => ({
default: { name: 'SubscriptionPanelContentWorkspace', template: '<div />' }
})
)
vi.mock(
'@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue',
() => ({
default: { name: 'MembersPanelContent', template: '<div />' }
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
function createMember(id: string): WorkspaceMember {
return {
id,
name: `Member ${id}`,
email: `member${id}@example.com`,
joinDate: new Date('2025-01-15'),
role: 'member',
isOriginalOwner: false
}
}
function renderComponent() {
return render(WorkspacePanelContent, {
global: {
plugins: [i18n],
stubs: { WorkspaceProfilePic: true }
}
})
}
describe('WorkspacePanelContent members tab label', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMembers.value = []
mockWorkspaceType.value = 'team'
})
it('shows the counted label for team workspaces with multiple members', () => {
mockMembers.value = [createMember('1'), createMember('2')]
renderComponent()
expect(screen.getByText(/workspacePanel\.tabs\.membersCount/)).toBeTruthy()
})
it('drops the count when the owner is the only member', () => {
mockMembers.value = [createMember('1')]
renderComponent()
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
})
it('shows the plain Members label for personal workspaces', () => {
mockWorkspaceType.value = 'personal'
mockMembers.value = [createMember('1'), createMember('2')]
renderComponent()
expect(screen.getByText('workspacePanel.members.header')).toBeTruthy()
expect(screen.queryByText(/workspacePanel\.tabs\.membersCount/)).toBeNull()
})
it('fetches members and pending invites on mount', () => {
renderComponent()
expect(mockFetchMembers).toHaveBeenCalled()
expect(mockFetchPendingInvites).toHaveBeenCalled()
})
})

View File

@@ -1,95 +0,0 @@
<template>
<div class="flex size-full flex-col">
<header class="mb-6 flex items-center gap-4">
<WorkspaceProfilePic
class="size-12 text-3xl!"
:workspace-name="workspaceName"
/>
<h1 class="text-3xl font-semibold text-base-foreground">
{{ workspaceName }}
</h1>
</header>
<TabsRoot v-model="activeTab">
<TabsList class="flex items-center gap-2 pb-1">
<TabsTrigger
value="plan"
:class="
cn(
tabTriggerBase,
activeTab === 'plan' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{ $t('workspacePanel.tabs.planCredits') }}
</TabsTrigger>
<TabsTrigger
value="members"
:class="
cn(
tabTriggerBase,
activeTab === 'members' ? tabTriggerActive : tabTriggerInactive
)
"
>
{{
showMembersTabCount
? $t('workspacePanel.tabs.membersCount', {
count: members.length
})
: $t('workspacePanel.members.header')
}}
</TabsTrigger>
</TabsList>
<TabsContent value="plan" class="mt-4">
<SubscriptionPanelContentWorkspace />
</TabsContent>
<TabsContent value="members" class="mt-4">
<MembersPanelContent :key="workspaceRole" />
</TabsContent>
</TabsRoot>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { cn } from '@comfyorg/tailwind-utils'
const tabTriggerBase =
'flex items-center justify-center shrink-0 px-2.5 py-2 text-sm rounded-lg cursor-pointer transition-all duration-200 outline-hidden border-none'
const tabTriggerActive =
'bg-interface-menu-component-surface-hovered text-text-primary font-bold'
const tabTriggerInactive =
'bg-transparent text-text-secondary hover:bg-button-hover-surface focus:bg-button-hover-surface'
const { defaultTab = 'plan' } = defineProps<{
defaultTab?: string
}>()
const workspaceStore = useTeamWorkspaceStore()
const { workspaceName, members } = storeToRefs(workspaceStore)
const { fetchMembers, fetchPendingInvites } = workspaceStore
const { workspaceType, workspaceRole } = useWorkspaceUI()
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
const activeTab = ref(defaultTab)
// Per design, the tab counts members only when there is more than the owner
const showMembersTabCount = computed(
() => !isPersonalWorkspace.value && members.value.length > 1
)
onMounted(() => {
fetchMembers()
fetchPendingInvites()
})
</script>

View File

@@ -0,0 +1,143 @@
<template>
<div class="flex min-w-0 items-center gap-4">
<div class="group relative size-12 shrink-0">
<WorkspaceProfilePic
class="size-12 rounded-lg text-2xl"
:workspace-name="workspaceName"
:image-url="imageUrl ?? undefined"
/>
<button
v-if="canEdit"
type="button"
class="absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg border-none bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
:aria-label="$t('workspacePanel.editWorkspaceImage')"
@click="pickImage"
>
<i class="icon-[lucide--pencil] size-4 text-white" />
</button>
<input
ref="fileInputRef"
type="file"
accept="image/*"
class="hidden"
@change="onFileChange"
/>
</div>
<input
v-if="isRenaming"
ref="inputRef"
v-model="draftName"
:maxlength="MAX_NAME_LENGTH"
class="min-w-0 flex-1 appearance-none border-none bg-transparent p-0 font-[inherit] text-2xl font-semibold text-base-foreground outline-none"
@keydown.enter="commit"
@keydown.esc="cancel"
@blur="commit"
/>
<h1
v-else
v-tooltip="
canEdit
? { value: $t('workspacePanel.doubleClickToRename'), showDelay: 300 }
: undefined
"
class="truncate text-2xl font-semibold text-base-foreground"
@dblclick="beginRename"
>
{{ workspaceName }}
</h1>
<span
v-if="isRenaming && remaining <= 10"
class="shrink-0 text-sm text-muted-foreground tabular-nums"
>
{{ $t('workspacePanel.charactersLeft', remaining) }}
</span>
</div>
</template>
<script setup lang="ts">
import { useToast } from 'primevue/usetoast'
import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
import { useWorkspaceRename } from '@/platform/workspace/composables/useWorkspaceRename'
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
import { WORKSPACE_NAME_MAX_LENGTH } from '@/platform/workspace/workspaceConstants'
const { t } = useI18n()
const toast = useToast()
const store = useTeamWorkspaceStore()
const { workspaceName } = storeToRefs(store)
const { uiConfig } = useWorkspaceUI()
const MAX_NAME_LENGTH = WORKSPACE_NAME_MAX_LENGTH
// Renaming is gated to Owner + Admins (and the sole owner of a personal
// workspace); Members never see the affordance.
const canEdit = computed(() => uiConfig.value.showEditWorkspaceMenuItem)
const { isRenaming, startRenaming, stopRenaming } = useWorkspaceRename()
const draftName = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
// A single entry point (double-click here or the "Rename" menu item) flips
// `isRenaming`; seed the draft and focus the field once the input mounts.
watch(isRenaming, (renaming) => {
if (!renaming) return
draftName.value = workspaceName.value
void nextTick(() => {
inputRef.value?.focus()
inputRef.value?.select()
})
})
// Surface the limit only as the user approaches it, to keep the header quiet.
const remaining = computed(() => MAX_NAME_LENGTH - draftName.value.length)
// Client-side only preview (prototype): the picked image is held locally, not
// uploaded or persisted. Resets on reload.
const imageUrl = ref<string | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
function pickImage() {
fileInputRef.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
const reader = new FileReader()
reader.onload = () => {
imageUrl.value = reader.result as string
}
reader.readAsDataURL(file)
}
function beginRename() {
if (!canEdit.value) return
startRenaming()
}
async function commit() {
if (!isRenaming.value) return
stopRenaming()
const name = draftName.value.trim()
if (!name || name === workspaceName.value) return
try {
await store.updateWorkspaceName(name)
} catch {
toast.add({
severity: 'error',
summary: t('workspacePanel.toast.failedToUpdateWorkspace')
})
}
}
function cancel() {
stopRenaming()
}
</script>

View File

@@ -0,0 +1,55 @@
import type { Ref } from 'vue'
import { onScopeDispose, ref, watch } from 'vue'
const FALLBACK_ROW_HEIGHT = 41
const MIN_ROWS = 5
/**
* Derive a table's rows-per-page from the live height of its scroll container so
* a taller dialog shows more rows instead of leaving empty space. Row and header
* heights are read from the rendered table, so it adapts if the design changes.
*/
export function useAutoPageSize(
containerRef: Ref<HTMLElement | null>,
min: number = MIN_ROWS
) {
const pageSize = ref(min)
function measure() {
const container = containerRef.value
if (!container) return
// Use fractional (getBoundingClientRect) heights, not the integer
// offsetHeight: truncating each row's height makes the floor below think an
// extra partial row fits, which overflows the container by a few pixels and
// shows a scrollbar. Fractional heights keep a not-quite-fitting row out.
const rowHeight =
container.querySelector<HTMLElement>('tbody tr')?.getBoundingClientRect()
.height || FALLBACK_ROW_HEIGHT
const headerHeight =
container.querySelector<HTMLElement>('thead')?.getBoundingClientRect()
.height ?? 0
const fit = Math.floor((container.clientHeight - headerHeight) / rowHeight)
pageSize.value = Math.max(min, fit)
}
let observer: ResizeObserver | null = null
watch(
containerRef,
(el) => {
observer?.disconnect()
if (!el) return
observer = new ResizeObserver(() => measure())
observer.observe(el)
// Also observe the table itself: async-loaded rows change the table's
// height without resizing the container, so the initial measure (taken
// against an empty state or fallback row height) would otherwise stick.
// Re-measuring converges — pageSize stabilizes once real rows exist.
const table = el.querySelector('table')
if (table) observer.observe(table)
},
{ immediate: true }
)
onScopeDispose(() => observer?.disconnect())
return { pageSize }
}

View File

@@ -0,0 +1,96 @@
import { computed, reactive } from 'vue'
import { creditsToCents } from '@/base/credits/comfyCredits'
interface AutoReloadConfig {
configured: boolean
enabled: boolean
thresholdCredits: number
reloadCredits: number
// null = no monthly budget set
monthlyBudgetCents: number | null
spentThisCycleCents: number
}
// TODO(auto-reload endpoint): there is no auto-reload API yet, so the config
// is client-side state — it starts unconfigured and edits don't persist
// across reloads.
const config = reactive<AutoReloadConfig>({
configured: false,
enabled: false,
thresholdCredits: 1000,
reloadCredits: 5000,
monthlyBudgetCents: null,
spentThisCycleCents: 0
})
export function useAutoReload() {
const isConfigured = computed(() => config.configured)
const isEnabled = computed(() => config.enabled)
const hasBudget = computed(() => config.monthlyBudgetCents != null)
const reloadCostCents = computed(() => creditsToCents(config.reloadCredits))
const budgetLeftCents = computed(() =>
config.monthlyBudgetCents == null
? 0
: Math.max(0, config.monthlyBudgetCents - config.spentThisCycleCents)
)
const budgetUsedFraction = computed(() =>
config.monthlyBudgetCents != null && config.monthlyBudgetCents > 0
? Math.min(1, config.spentThisCycleCents / config.monthlyBudgetCents)
: 0
)
const reloadsLeft = computed(() =>
hasBudget.value
? Math.floor(budgetLeftCents.value / reloadCostCents.value)
: null
)
// Budget drained while still enabled → auto-reload can't fire, so it's paused.
const isPaused = computed(
() => config.enabled && hasBudget.value && budgetLeftCents.value <= 0
)
// One reload (or none) of headroom left before the budget pauses it.
const isWarning = computed(
() =>
config.enabled &&
hasBudget.value &&
!isPaused.value &&
(reloadsLeft.value ?? Infinity) <= 1
)
function setEnabled(value: boolean) {
config.enabled = value
}
function save(next: {
thresholdCredits: number
reloadCredits: number
monthlyBudgetCents: number | null
}) {
config.configured = true
config.enabled = true
config.thresholdCredits = next.thresholdCredits
config.reloadCredits = next.reloadCredits
config.monthlyBudgetCents = next.monthlyBudgetCents
}
return {
config,
isConfigured,
isEnabled,
hasBudget,
reloadCostCents,
budgetLeftCents,
budgetUsedFraction,
reloadsLeft,
isPaused,
isWarning,
setEnabled,
save
}
}

View File

@@ -254,6 +254,7 @@ const mockShowChangeMemberRoleDialog = vi.fn()
const mockShowSubscriptionDialog = vi.fn()
const mockShowInviteMemberDialog = vi.fn()
const mockShowInviteMemberUpsellDialog = vi.fn()
const mockShowMemberLimitDialog = vi.fn()
const {
mockMembers,
@@ -366,6 +367,9 @@ vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({
isActiveSubscription: mockIsActiveSubscription,
subscription: mockSubscription,
balance: { value: null },
renewalDate: { value: null },
fetchBalance: vi.fn(),
getMaxSeats: (tierKey: string) => {
const seats: Record<string, number> = {
free: 1,
@@ -391,7 +395,8 @@ vi.mock('@/services/dialogService', () => ({
showRevokeInviteDialog: mockShowRevokeInviteDialog,
showChangeMemberRoleDialog: mockShowChangeMemberRoleDialog,
showInviteMemberDialog: mockShowInviteMemberDialog,
showInviteMemberUpsellDialog: mockShowInviteMemberUpsellDialog
showInviteMemberUpsellDialog: mockShowInviteMemberUpsellDialog,
showMemberLimitDialog: mockShowMemberLimitDialog
})
}))
@@ -593,13 +598,13 @@ describe('useMembersPanel', () => {
const roleItems = items[0].items ?? []
expect(roleItems.map((i) => i.label)).toEqual([
'workspaceSwitcher.roleOwner',
'workspaceSwitcher.roleAdmin',
'workspaceSwitcher.roleMember'
])
expect(roleItems.map((i) => i.checked)).toEqual([false, true])
})
it('checks Owner for owner rows', async () => {
it('checks Admin for owner-role rows', async () => {
const panel = await setup()
const items = panel.memberMenuItems(createMember({ role: 'owner' }))
const roleItems = items[0].items ?? []
@@ -706,14 +711,15 @@ describe('useMembersPanel', () => {
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
})
it('disables the invite button at the member cap (30)', async () => {
it('opens the member-limit dialog at the member cap (30)', async () => {
mockTotalMemberSlots.value = 30
const panel = await setup()
expect(panel.isInviteDisabled.value).toBe(true)
expect(panel.isInviteDisabled.value).toBe(false)
expect(panel.inviteTooltip.value).toBe(
'workspacePanel.inviteLimitReached'
)
panel.handleInviteMember()
expect(mockShowMemberLimitDialog).toHaveBeenCalled()
expect(mockShowInviteMemberDialog).not.toHaveBeenCalled()
})
@@ -724,10 +730,12 @@ describe('useMembersPanel', () => {
expect(panel.inviteTooltip.value).toBeNull()
})
it('disables the invite button at the flat backend member cap', async () => {
it('opens the member-limit dialog at the flat backend member cap', async () => {
mockIsInviteLimitReached.value = true
const panel = await setup()
expect(panel.isInviteDisabled.value).toBe(true)
expect(panel.isInviteDisabled.value).toBe(false)
panel.handleInviteMember()
expect(mockShowMemberLimitDialog).toHaveBeenCalled()
})
it('disables the invite button when not on a team plan', async () => {

View File

@@ -5,6 +5,7 @@ import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useSubscriptionDialog } from '@/platform/cloud/subscription/composables/useSubscriptionDialog'
import type { WorkspaceRole } from '@/platform/workspace/api/workspaceApi'
import { useTeamPlan } from '@/platform/workspace/composables/useTeamPlan'
@@ -20,15 +21,44 @@ import {
import { useDialogService } from '@/services/dialogService'
type ActiveView = 'active' | 'pending'
type SortField = 'inviteDate' | 'expiryDate' | 'role'
type SortField =
| 'email'
| 'role'
| 'lastActivity'
| 'credits'
| 'inviteDate'
| 'expiryDate'
type SortDirection = 'asc' | 'desc'
// One-shot sort applied the next time the Members panel mounts, so other panels
// can deep-link in with a preset ordering (e.g. Overview "Top spenders").
const pendingSort = ref<SortField | null>(null)
export function requestMembersSort(field: 'credits' | 'lastActivity') {
pendingSort.value = field
}
export function sortMembers(
members: WorkspaceMember[],
currentUserEmail: string | null,
sortDirection: SortDirection,
originalOwnerId: string | null = null
originalOwnerId: string | null = null,
sortField: SortField = 'role'
): WorkspaceMember[] {
const dir = sortDirection === 'asc' ? 1 : -1
if (sortField === 'email') {
return [...members].sort((a, b) => dir * a.name.localeCompare(b.name))
}
if (sortField === 'lastActivity') {
const at = (m: WorkspaceMember) => m.lastActivity?.getTime() ?? 0
return [...members].sort((a, b) => dir * (at(a) - at(b)))
}
if (sortField === 'credits') {
const used = (m: WorkspaceMember) => m.creditsUsedThisMonth ?? 0
return [...members].sort((a, b) => dir * (used(a) - used(b)))
}
// Default (role) ordering pins the creator, then groups by role, then recency.
return [...members].sort((a, b) => {
const aIsOriginalOwner = a.id === originalOwnerId
const bIsOriginalOwner = b.id === originalOwnerId
@@ -97,7 +127,8 @@ export function useMembersPanel() {
showRevokeInviteDialog,
showChangeMemberRoleDialog,
showInviteMemberDialog,
showInviteMemberUpsellDialog
showInviteMemberUpsellDialog,
showMemberLimitDialog
} = useDialogService()
const workspaceStore = useTeamWorkspaceStore()
const {
@@ -112,11 +143,14 @@ export function useMembersPanel() {
const { permissions, uiConfig } = useWorkspaceUI()
const { isOnTeamPlan, isCancelled, hasLapsedTeamPlan } = useTeamPlan()
const subscriptionDialog = useSubscriptionDialog()
const { fetchBalance } = useBillingContext()
// The team plan caps members at a flat MAX_WORKSPACE_MEMBERS, independent of
// the subscription tier.
const maxSeats = computed(() => MAX_WORKSPACE_MEMBERS)
const memberCount = computed(() => members.value.length)
const hasMultipleMembers = computed(() => members.value.length > 1)
const showSearch = computed(
@@ -138,16 +172,16 @@ export function useMembersPanel() {
() => isInviteLimitReached.value || totalMemberSlots.value >= maxSeats.value
)
// Invite is allowed only on an active (non-cancelled) team plan that is under
// the member cap.
// Invite stays enabled at the seat cap so the button can surface the
// "at the member limit" dialog; only an inactive/cancelled plan disables it.
const isInviteDisabled = computed(
() => !isOnTeamPlan.value || isCancelled.value || isMemberLimitReached.value
() => !isOnTeamPlan.value || isCancelled.value
)
const inviteTooltip = computed(() => {
if (!isOnTeamPlan.value) return null
if (!isMemberLimitReached.value) return null
return t('workspacePanel.inviteLimitReached', { count: maxSeats.value })
return t('workspacePanel.inviteLimitReached')
})
function handleInviteMember() {
@@ -155,7 +189,11 @@ export function useMembersPanel() {
void showInviteMemberUpsellDialog()
return
}
if (isCancelled.value || isMemberLimitReached.value) return
if (isCancelled.value) return
if (isMemberLimitReached.value) {
void showMemberLimitDialog()
return
}
void showInviteMemberDialog()
}
@@ -170,8 +208,9 @@ export function useMembersPanel() {
const searchQuery = ref('')
const activeView = ref<ActiveView>('active')
const sortField = ref<SortField>('inviteDate')
const sortField = ref<SortField>(pendingSort.value ?? 'inviteDate')
const sortDirection = ref<SortDirection>('desc')
pendingSort.value = null
function roleMenuItem(
member: WorkspaceMember,
@@ -190,7 +229,7 @@ export function useMembersPanel() {
{
label: t('workspacePanel.members.actions.changeRole'),
items: [
roleMenuItem(member, 'owner', t('workspaceSwitcher.roleOwner')),
roleMenuItem(member, 'owner', t('workspaceSwitcher.roleAdmin')),
roleMenuItem(member, 'member', t('workspaceSwitcher.roleMember'))
]
},
@@ -215,13 +254,14 @@ export function useMembersPanel() {
searched,
userEmail.value ?? null,
sortDirection.value,
originalOwnerId.value
originalOwnerId.value,
sortField.value
)
})
// Built once per member list rather than per row on every render, so an
// unrelated re-render (e.g. typing in the search box) doesn't rebuild every
// row's menu and churn MemberListItem's props.
// row's menu and churn MemberTableRow's props.
const memberMenus = computed(
() => new Map(filteredMembers.value.map((m) => [m.id, memberMenuItems(m)]))
)
@@ -286,9 +326,11 @@ export function useMembersPanel() {
sortField,
sortDirection,
maxSeats,
memberCount,
isOnTeamPlan,
hasLapsedTeamPlan,
hasMultipleMembers,
fetchBalance,
showSearch,
showViewTabs,
showInviteButton,

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