fix(web): separate skill and suite discovery

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-09 09:06:38 +08:00
parent 859987e3bb
commit a838078cd9
8 changed files with 257 additions and 228 deletions

View file

@ -228,12 +228,11 @@ const searchRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'search',
component: SearchPage,
validateSearch: (search: Record<string, unknown>): { q: string; namespace?: string; label?: string; resourceType?: 'SKILL' | 'SUITE'; sort: string; page: number; starredOnly: boolean } => {
validateSearch: (search: Record<string, unknown>): { q: string; namespace?: string; label?: string; sort: string; page: number; starredOnly: boolean } => {
return {
q: normalizeSearchQuery(typeof search.q === 'string' ? search.q : ''),
namespace: typeof search.namespace === 'string' && search.namespace ? search.namespace.replace(/^@/, '') : undefined,
label: typeof search.label === 'string' && search.label ? search.label : undefined,
resourceType: search.resourceType === 'SKILL' || search.resourceType === 'SUITE' ? search.resourceType : undefined,
sort: (search.sort as string) || 'newest',
page: Number(search.page) || 0,
starredOnly: search.starredOnly === true || search.starredOnly === 'true',

View file

@ -1763,6 +1763,8 @@
"available": "Available",
"degraded": "Degraded",
"installCommand": "Install command",
"copyInstallCommand": "Copy install command",
"detailsSidebar": "Suite information and actions",
"commandCopied": "Install command copied",
"degradedDescription": "A member or its access changed. Restore every member before installing this suite.",
"membersTitle": "Included skills ({{count}})",

View file

@ -1760,6 +1760,8 @@
"available": "Доступно",
"degraded": "Ограничено",
"installCommand": "Команда установки",
"copyInstallCommand": "Копировать команду установки",
"detailsSidebar": "Информация о наборе и действия",
"commandCopied": "Команда установки скопирована",
"degradedDescription": "Участник или права доступа изменились. Восстановите всех участников перед установкой.",
"membersTitle": "Включённые навыки ({{count}})",

View file

@ -1762,6 +1762,8 @@
"available": "可用",
"degraded": "已降级",
"installCommand": "安装命令",
"copyInstallCommand": "复制安装命令",
"detailsSidebar": "套件信息与操作",
"commandCopied": "已复制安装命令",
"degradedDescription": "成员失效或权限发生变化,恢复所有成员后才能安装。",
"membersTitle": "包含的技能({{count}})",

View file

@ -46,10 +46,6 @@ vi.mock('@/features/skill/skill-card', () => ({
SkillCard: () => <div>skill-card</div>,
}))
vi.mock('@/features/suite/resource-card', () => ({
ResourceCard: () => <div>resource-card</div>,
}))
vi.mock('@/shared/components/skeleton-loader', () => ({
SkeletonList: () => <div>skeleton</div>,
}))
@ -92,7 +88,6 @@ vi.mock('@/app/page-shell-style', () => ({
}))
const useSearchSkillsMock = vi.fn()
const useResourceSearchMock = vi.fn()
vi.mock('@/shared/hooks/use-skill-queries', () => ({
useSearchSkills: (params: Record<string, unknown>) => {
@ -101,10 +96,6 @@ vi.mock('@/shared/hooks/use-skill-queries', () => ({
},
}))
vi.mock('@/shared/hooks/use-suite-queries', () => ({
useResourceSearch: () => useResourceSearchMock(),
}))
vi.mock('@/shared/hooks/use-label-queries', () => ({
useVisibleLabels: () => ({
data: [
@ -157,11 +148,6 @@ describe('SearchPage', () => {
isLoading: false,
isFetching: false,
})
useResourceSearchMock.mockReturnValue({
data: { items: [], total: 0, page: 0, size: 12 },
isLoading: false,
isFetching: false,
})
})
it('marks the selected label button as active on initial render', () => {
@ -284,9 +270,9 @@ describe('SearchPage', () => {
page: 0,
starredOnly: false,
})
useResourceSearchMock.mockReturnValue({
useSearchSkillsMock.mockReturnValue({
data: {
items: [{ resourceType: 'SKILL', detailUrl: '/space/global/demo', id: 1, displayName: 'Demo Skill', summary: 'summary', namespace: 'global', slug: 'demo', version: '1.0.0', visibility: 'PUBLIC', installCount: 1, available: true, updatedAt: '2026-03-20T00:00:00Z' }],
items: [{ id: 1, displayName: 'Demo Skill', summary: 'summary', namespace: 'global', slug: 'demo', downloadCount: 1, starCount: 1, ratingCount: 0, updatedAt: '2026-03-20T00:00:00Z', canSubmitPromotion: false }],
total: 1,
page: 0,
size: 12,
@ -297,7 +283,8 @@ describe('SearchPage', () => {
const html = renderToStaticMarkup(<SearchPage />)
expect(html).toContain('resource-card')
expect(html).toContain('skill-card')
expect(html).not.toContain('suite.resourceTypeSuite')
expect(html).not.toContain('empty-state')
})
@ -309,7 +296,7 @@ describe('SearchPage', () => {
page: 0,
starredOnly: false,
})
useResourceSearchMock.mockReturnValue({
useSearchSkillsMock.mockReturnValue({
data: {
items: [],
total: 0,
@ -326,4 +313,20 @@ describe('SearchPage', () => {
expect(html).toContain('search.noResults')
expect(html).not.toContain('search.enterKeyword')
})
it('keeps search skill-only when an obsolete Suite type query parameter is present', () => {
useSearchMock.mockReturnValue({
q: 'workflow',
resourceType: 'SUITE',
sort: 'newest',
page: 0,
starredOnly: false,
})
const html = renderToStaticMarkup(<SearchPage />)
expect(searchSkillParams[0]).not.toHaveProperty('resourceType')
expect(html).toContain('skill-card')
expect(html).not.toContain('suite.resourceTypeSuite')
})
})

View file

@ -2,16 +2,14 @@ import { startTransition, useEffect, useRef, useState } from 'react'
import { useNavigate, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Loader2 } from 'lucide-react'
import type { ResourceType, SkillSummary } from '@/api/types'
import type { SkillSummary } from '@/api/types'
import { useAuth } from '@/features/auth/use-auth'
import { SearchBar } from '@/features/search/search-bar'
import { SkillCard } from '@/features/skill/skill-card'
import { ResourceCard } from '@/features/suite/resource-card'
import { SkeletonList } from '@/shared/components/skeleton-loader'
import { EmptyState } from '@/shared/components/empty-state'
import { Pagination } from '@/shared/components/pagination'
import { useSearchSkills } from '@/shared/hooks/use-skill-queries'
import { useResourceSearch } from '@/shared/hooks/use-suite-queries'
import { useVisibleLabels } from '@/shared/hooks/use-label-queries'
import { useMyStars } from '@/shared/hooks/use-user-queries'
import { toRouterPath } from '@/shared/lib/base-path'
@ -96,7 +94,6 @@ export function SearchPage() {
const q = normalizeSearchQuery(searchParams.q || '')
const namespace = (searchParams.namespace || '').replace(/^@/, '')
const selectedLabel = searchParams.label || ''
const resourceType = searchParams.resourceType
const sort = searchParams.sort || 'newest'
const page = searchParams.page ?? 0
const starredOnly = searchParams.starredOnly ?? false
@ -121,7 +118,6 @@ export function SearchPage() {
previousPageRef.current = page
}, [page])
const useLegacySkillSearch = starredOnly || Boolean(selectedLabel)
const { data, isLoading, isFetching } = useSearchSkills({
q,
namespace: namespace || undefined,
@ -130,19 +126,7 @@ export function SearchPage() {
page,
size: PAGE_SIZE,
starredOnly,
}, useLegacySkillSearch)
const {
data: resourceData,
isLoading: isLoadingResources,
isFetching: isFetchingResources,
} = useResourceSearch({
q,
namespace: namespace || undefined,
resourceType,
sort,
page,
size: PAGE_SIZE,
}, !useLegacySkillSearch)
}, !starredOnly)
const { data: labels } = useVisibleLabels()
const {
data: starredSkills,
@ -159,44 +143,44 @@ export function SearchPage() {
if (!parsedInput.query && !parsedInput.namespace) {
startTransition(() => {
navigate({ to: '/search', search: { q: '', namespace: '', label: selectedLabel, resourceType, sort, page: 0, starredOnly }, replace: page === 0 })
navigate({ to: '/search', search: { q: '', namespace: '', label: selectedLabel, sort, page: 0, starredOnly }, replace: page === 0 })
})
return
}
const timeoutId = window.setTimeout(() => {
startTransition(() => {
navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, resourceType, sort, page: 0, starredOnly }, replace: true })
navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true })
})
}, 250)
return () => window.clearTimeout(timeoutId)
}, [navigate, namespace, page, q, queryInput, resourceType, selectedLabel, sort, starredOnly])
}, [navigate, namespace, page, q, queryInput, selectedLabel, sort, starredOnly])
const handleSearch = (query: string) => {
const parsedInput = parseNamespaceSearchInput(query)
setQueryInput(query)
startTransition(() => {
navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, resourceType, sort, page: 0, starredOnly }, replace: true })
navigate({ to: '/search', search: { q: parsedInput.query, namespace: parsedInput.namespace, label: selectedLabel, sort, page: 0, starredOnly }, replace: true })
})
}
const handleSortChange = (newSort: string) => {
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, resourceType, sort: newSort, page: 0, starredOnly } })
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort: newSort, page: 0, starredOnly } })
}
const handlePageChange = (newPage: number) => {
blurActiveElement()
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, resourceType, sort, page: newPage, starredOnly } })
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: newPage, starredOnly } })
}
const handleLabelToggle = (label: string) => {
const nextLabel = selectedLabel === label ? '' : label
navigate({ to: '/search', search: { q, namespace, label: nextLabel, resourceType: undefined, sort, page: 0, starredOnly } })
navigate({ to: '/search', search: { q, namespace, label: nextLabel, sort, page: 0, starredOnly } })
}
const handleNamespaceClear = () => {
navigate({ to: '/search', search: { q, namespace: '', label: selectedLabel, resourceType, sort, page: 0, starredOnly } })
navigate({ to: '/search', search: { q, namespace: '', label: selectedLabel, sort, page: 0, starredOnly } })
}
const handleStarredToggle = () => {
@ -210,7 +194,7 @@ export function SearchPage() {
return
}
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, resourceType: undefined, sort, page: 0, starredOnly: !starredOnly } })
navigate({ to: '/search', search: { q, namespace, label: selectedLabel, sort, page: 0, starredOnly: !starredOnly } })
}
const handleSkillClick = (namespace: string, slug: string) => {
@ -220,13 +204,6 @@ export function SearchPage() {
})
}
const handleResourceTypeChange = (nextType?: ResourceType) => {
navigate({
to: '/search',
search: { q, namespace, label: '', resourceType: nextType, sort, page: 0, starredOnly: false },
})
}
const filteredStarredSkills = starredOnly
? sortStarredSkills(filterStarredSkills(starredSkills ?? [], q, namespace), sort)
: []
@ -235,26 +212,19 @@ export function SearchPage() {
: []
const totalPages = starredOnly
? Math.ceil(filteredStarredSkills.length / PAGE_SIZE)
: useLegacySkillSearch && data
: data
? Math.ceil(data.total / data.size)
: resourceData
? Math.ceil(resourceData.total / resourceData.size)
: 0
: 0
const displayItems = starredOnly
? starredPageItems
: useLegacySkillSearch
? (data?.items ?? [])
: []
const resourceItems = useLegacySkillSearch ? [] : (resourceData?.items ?? [])
const isPageLoading = starredOnly ? isLoadingStarred : useLegacySkillSearch ? isLoading : isLoadingResources
: (data?.items ?? [])
const isPageLoading = starredOnly ? isLoadingStarred : isLoading
const isUpdatingResults = starredOnly
? isFetchingStarred && !isLoadingStarred
: useLegacySkillSearch
? isFetching && !isLoading
: isFetchingResources && !isLoadingResources
: isFetching && !isLoading
const resultCount = starredOnly
? filteredStarredSkills.length
: useLegacySkillSearch ? (data?.total ?? 0) : (resourceData?.total ?? 0)
: (data?.total ?? 0)
return (
<div className={APP_SHELL_PAGE_CLASS_NAME}>
@ -313,10 +283,6 @@ export function SearchPage() {
) : null}
<div className="flex flex-wrap items-center gap-2">
<span className="shrink-0 text-sm font-medium text-muted-foreground">{t('suite.resourceType')}</span>
<Button variant={!resourceType && !useLegacySkillSearch ? 'default' : 'outline'} size="sm" onClick={() => handleResourceTypeChange(undefined)}>{t('suite.allTypes')}</Button>
<Button variant={resourceType === 'SKILL' ? 'default' : 'outline'} size="sm" onClick={() => handleResourceTypeChange('SKILL')}>{t('suite.resourceTypeSkill')}</Button>
<Button variant={resourceType === 'SUITE' ? 'default' : 'outline'} size="sm" onClick={() => handleResourceTypeChange('SUITE')}>{t('suite.resourceTypeSuite')}</Button>
<span className="shrink-0 text-sm font-medium text-muted-foreground">{t('search.filters.label')}</span>
<Button
variant={starredOnly ? 'default' : 'outline'}
@ -350,7 +316,7 @@ export function SearchPage() {
{/* Results */}
{isPageLoading ? (
<SkeletonList count={PAGE_SIZE} />
) : displayItems.length > 0 || resourceItems.length > 0 ? (
) : displayItems.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{displayItems.map((skill, idx) => (
@ -362,16 +328,6 @@ export function SearchPage() {
/>
</div>
))}
{resourceItems.map((resource, idx) => (
<div key={`${resource.resourceType}-${resource.id}`} className={`h-full animate-fade-up delay-${Math.min(idx % 6 + 1, 6)}`}>
<ResourceCard
resource={resource}
onClick={() => resource.resourceType === 'SUITE'
? navigate({ to: `/suite/${resource.namespace}/${encodeURIComponent(resource.slug)}` })
: handleSkillClick(resource.namespace, resource.slug)}
/>
</div>
))}
</div>
{totalPages > 1 && (
<Pagination

View file

@ -1,6 +1,6 @@
/** @vitest-environment jsdom */
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SkillSuite } from '@/api/types'
@ -119,4 +119,15 @@ describe('SuiteDetailPage', () => {
expect(deletedLabels.every((label) => label.closest('a') === null)).toBe(true)
expect(screen.getAllByRole('link')).toHaveLength(1)
})
it('places Suite metadata and installation in the detail sidebar', () => {
mocks.detail = { data: suite(), isLoading: false, error: null }
render(<SuiteDetailPage />)
const sidebar = screen.getByRole('complementary', { name: 'suite.detailsSidebar' })
expect(within(sidebar).getByText('v1.0.0')).not.toBeNull()
expect(within(sidebar).getByText('suite.installCommand')).not.toBeNull()
expect(within(sidebar).getByLabelText('suite.copyInstallCommand')).not.toBeNull()
})
})

View file

@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { Link, useNavigate, useParams, useSearch } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { AlertTriangle, ArrowUpRight, Boxes, CheckCircle2, Copy, Wrench } from 'lucide-react'
import { AlertTriangle, ArrowUpRight, Boxes, CheckCircle2, Copy, Terminal, Wrench } from 'lucide-react'
import { useSuiteDetail, useSuiteVersions, useSubmitSuite } from '@/shared/hooks/use-suite-queries'
import { suiteBlockingReasonLabel, suiteStatusLabel, suiteVisibilityLabel } from '@/features/suite/suite-labels'
import { SuiteManagementActions } from '@/features/suite/suite-management-actions'
@ -10,7 +10,9 @@ import { Card } from '@/shared/ui/card'
import { Button } from '@/shared/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/shared/ui/tabs'
import { SkeletonList } from '@/shared/components/skeleton-loader'
import { NamespaceBadge } from '@/shared/components/namespace-badge'
import { toast } from '@/shared/lib/toast'
import { cn } from '@/shared/lib/utils'
import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style'
export function SuiteDetailPage() {
@ -48,161 +50,213 @@ export function SuiteDetailPage() {
}
}
const hasPrimaryActions = suite.allowedActions.includes('EDIT')
|| suite.allowedActions.includes('SUBMIT')
|| suite.allowedActions.includes('PUBLISH_PRIVATE')
return (
<div className={APP_SHELL_PAGE_CLASS_NAME}>
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-start">
<div>
<div className="mb-2 inline-flex items-center gap-2 text-sm font-medium text-primary">
<Boxes className="h-4 w-4" /> @{suite.namespace}/{suite.slug}
<div className={cn(APP_SHELL_PAGE_CLASS_NAME, 'mx-auto max-w-6xl')}>
<div className="flex flex-col gap-8 lg:flex-row">
<div className="min-w-0 flex-1 space-y-8">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<NamespaceBadge type={suite.namespace === 'global' ? 'GLOBAL' : 'TEAM'} name={suite.namespace} />
<span className="badge-soft badge-soft-blue inline-flex items-center gap-1.5">
<Boxes className="h-3.5 w-3.5" aria-hidden="true" />
{t('suite.resourceTypeSuite')}
</span>
</div>
<h1 className="text-balance break-words text-4xl font-bold font-heading text-foreground [overflow-wrap:anywhere]">
{suite.displayName}
</h1>
<p className="font-mono text-sm text-muted-foreground">@{suite.namespace}/{suite.slug}</p>
<p className="text-lg leading-relaxed text-muted-foreground">
{suite.summary || t('suite.noSummary')}
</p>
</div>
<h1 className="text-4xl font-bold">{suite.displayName}</h1>
<p className="mt-3 max-w-3xl text-muted-foreground">{suite.summary || t('suite.noSummary')}</p>
</div>
<div className="flex flex-wrap gap-2">
{suite.allowedActions.includes('EDIT') ? (
<Button variant="outline" onClick={() => navigate({
to: `/dashboard/suites/${suite.namespace}/${encodeURIComponent(suite.slug)}/edit`,
search: { version: suite.version },
})}>{t('suite.editDraft')}</Button>
{!suite.available ? (
<Card className="border-amber-500/30 bg-amber-500/5 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" aria-hidden="true" />
<div>
<p className="font-medium text-foreground">{t('suite.degraded')}</p>
<p className="mt-1 text-sm text-muted-foreground">{t('suite.degradedDescription')}</p>
</div>
</div>
</Card>
) : null}
{suite.allowedActions.includes('SUBMIT') || suite.allowedActions.includes('PUBLISH_PRIVATE') ? (
<Button disabled={submitMutation.isPending} onClick={handleSubmit}>
{suite.visibility === 'PRIVATE' ? t('suite.publishDirectly') : t('suite.submitReview')}
</Button>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">{t('suite.overviewTab')}</TabsTrigger>
<TabsTrigger value="members">{t('suite.membersTab', { count: suite.members.length })}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-6">
<Card className="p-8">
{suite.overview ? (
<MarkdownRenderer content={suite.overview} />
) : (
<p className="text-sm leading-7 text-muted-foreground">
{suite.summary || t('suite.noOverview')}
</p>
)}
</Card>
</TabsContent>
<TabsContent value="members" className="mt-6">
<div className="grid gap-4 md:grid-cols-2">
{suite.members.map((member) => {
const memberName = member.displayName || `@${member.namespace}/${member.slug}`
const content = (
<Card className="h-full p-5 transition-colors hover:border-primary/40">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="flex items-center gap-2 font-semibold">
<Wrench className="h-4 w-4 shrink-0 text-primary" />
<span className="truncate">{memberName}</span>
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
@{member.namespace}/{member.slug}
</p>
</div>
{member.browsable && !member.blockingReason && member.skillId && member.skillVersionId ? (
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
) : null}
</div>
{member.summary ? (
<p className="mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground">{member.summary}</p>
) : null}
<div className="mt-5 flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-full bg-secondary px-2.5 py-1 font-mono">
{t('suite.pinnedVersion', { version: member.version })}
</span>
{member.entry ? (
<span className="rounded-full bg-primary/10 px-2.5 py-1 font-medium text-primary">
{t('suite.entrySkill')}
</span>
) : null}
<span className={member.blockingReason ? 'text-destructive' : 'text-emerald-600'}>
{member.blockingReason
? suiteBlockingReasonLabel(t, member.blockingReason)
: t('suite.available')}
</span>
</div>
</Card>
)
return member.browsable && !member.blockingReason && member.skillId && member.skillVersionId ? (
<Link
key={`${member.namespace}/${member.slug}@${member.version}`}
to="/space/$namespace/$slug"
params={{ namespace: member.namespace, slug: member.slug }}
search={{ returnTo }}
aria-label={t('suite.viewMember', { name: memberName })}
className="rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
>
{content}
</Link>
) : (
<div key={`${member.namespace}/${member.slug}@${member.version}`}>{content}</div>
)
})}
</div>
</TabsContent>
</Tabs>
</div>
<aside
className="w-full flex-shrink-0 space-y-5 lg:w-80"
aria-label={t('suite.detailsSidebar')}
>
{hasPrimaryActions ? (
<Card className="space-y-3 p-5">
{suite.allowedActions.includes('EDIT') ? (
<Button className="w-full" variant="outline" onClick={() => navigate({
to: `/dashboard/suites/${suite.namespace}/${encodeURIComponent(suite.slug)}/edit`,
search: { version: suite.version },
})}>{t('suite.editDraft')}</Button>
) : null}
{suite.allowedActions.includes('SUBMIT') || suite.allowedActions.includes('PUBLISH_PRIVATE') ? (
<Button className="w-full" disabled={submitMutation.isPending} onClick={handleSubmit}>
{suite.visibility === 'PRIVATE' ? t('suite.publishDirectly') : t('suite.submitReview')}
</Button>
) : null}
</Card>
) : null}
</div>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card className="p-4"><p className="text-xs text-muted-foreground">{t('suite.version')}</p><p className="mt-1 font-mono font-semibold">v{suite.version}</p></Card>
<Card className="p-4"><p className="text-xs text-muted-foreground">{t('suite.status')}</p><p className="mt-1 font-semibold">{suiteStatusLabel(t, suite.status)}</p></Card>
<Card className="p-4"><p className="text-xs text-muted-foreground">{t('suite.visibility')}</p><p className="mt-1 font-semibold">{suiteVisibilityLabel(t, suite.visibility)}</p></Card>
<Card className="p-4">
<p className="text-xs text-muted-foreground">{t('suite.installStatus')}</p>
<p className={`mt-1 flex items-center gap-1 font-semibold ${suite.available ? 'text-emerald-600' : 'text-destructive'}`}>
{suite.available ? <CheckCircle2 className="h-4 w-4" /> : <AlertTriangle className="h-4 w-4" />}
{suite.available ? t('suite.available') : t('suite.degraded')}
</p>
</Card>
</div>
<Card className="p-6">
<h2 className="text-lg font-semibold">{t('suite.installCommand')}</h2>
<div className="mt-3 flex items-center gap-2 rounded-lg bg-secondary p-3">
<code className="min-w-0 flex-1 overflow-x-auto text-sm">{command}</code>
<Button
variant="outline"
size="sm"
onClick={async () => {
await navigator.clipboard.writeText(command)
toast.success(t('suite.commandCopied'))
}}
><Copy className="h-4 w-4" /></Button>
</div>
{!suite.available ? <p className="mt-3 text-sm text-destructive">{t('suite.degradedDescription')}</p> : null}
</Card>
<Tabs defaultValue="overview">
<TabsList className="w-full justify-start">
<TabsTrigger value="overview">{t('suite.overviewTab')}</TabsTrigger>
<TabsTrigger value="members">{t('suite.membersTab', { count: suite.members.length })}</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-5">
<Card className="p-6 md:p-8">
<h2 className="text-xl font-semibold">{t('suite.overviewTitle')}</h2>
<div className="mt-5">
{suite.overview ? (
<MarkdownRenderer content={suite.overview} />
) : (
<p className="text-sm leading-7 text-muted-foreground">
{suite.summary || t('suite.noOverview')}
</p>
)}
<Card className="space-y-5 p-5">
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground">{t('suite.version')}</span>
<span className="break-all text-right font-mono font-semibold text-foreground">v{suite.version}</span>
</div>
<div className="h-px bg-border/40" />
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground">{t('suite.status')}</span>
<span className="text-right font-semibold text-foreground">{suiteStatusLabel(t, suite.status)}</span>
</div>
<div className="h-px bg-border/40" />
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground">{t('suite.visibility')}</span>
<span className="text-right font-semibold text-foreground">{suiteVisibilityLabel(t, suite.visibility)}</span>
</div>
<div className="h-px bg-border/40" />
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground">{t('suite.installStatus')}</span>
<span className={cn(
'flex items-center gap-1 text-right font-semibold',
suite.available ? 'text-emerald-600' : 'text-destructive',
)}>
{suite.available
? <CheckCircle2 className="h-4 w-4" aria-hidden="true" />
: <AlertTriangle className="h-4 w-4" aria-hidden="true" />}
{suite.available ? t('suite.available') : t('suite.degraded')}
</span>
</div>
</Card>
</TabsContent>
<TabsContent value="members" className="mt-5">
<div className="grid gap-4 md:grid-cols-2">
{suite.members.map((member) => {
const memberName = member.displayName || `@${member.namespace}/${member.slug}`
const content = (
<Card className="h-full p-5 transition-colors hover:border-primary/40">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="flex items-center gap-2 font-semibold">
<Wrench className="h-4 w-4 shrink-0 text-primary" />
<span className="truncate">{memberName}</span>
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
@{member.namespace}/{member.slug}
</p>
</div>
{member.browsable && !member.blockingReason && member.skillId && member.skillVersionId ? (
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
) : null}
</div>
{member.summary ? (
<p className="mt-4 line-clamp-3 text-sm leading-6 text-muted-foreground">{member.summary}</p>
) : null}
<div className="mt-5 flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-full bg-secondary px-2.5 py-1 font-mono">
{t('suite.pinnedVersion', { version: member.version })}
</span>
{member.entry ? (
<span className="rounded-full bg-primary/10 px-2.5 py-1 font-medium text-primary">
{t('suite.entrySkill')}
</span>
) : null}
<span className={member.blockingReason ? 'text-destructive' : 'text-emerald-600'}>
{member.blockingReason
? suiteBlockingReasonLabel(t, member.blockingReason)
: t('suite.available')}
</span>
</div>
</Card>
)
return member.browsable && !member.blockingReason && member.skillId && member.skillVersionId ? (
<Link
key={`${member.namespace}/${member.slug}@${member.version}`}
to="/space/$namespace/$slug"
params={{ namespace: member.namespace, slug: member.slug }}
search={{ returnTo }}
aria-label={t('suite.viewMember', { name: memberName })}
className="rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
>
{content}
</Link>
) : (
<div key={`${member.namespace}/${member.slug}@${member.version}`}>{content}</div>
)
})}
</div>
</TabsContent>
</Tabs>
{versions?.length ? (
<Card className="p-6">
<h2 className="text-lg font-semibold">{t('suite.versionHistory')}</h2>
<div className="mt-4 flex flex-wrap gap-2">
{versions.map((item) => (
<Card className="space-y-4 p-5">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<span className="text-sm font-semibold font-heading text-foreground">{t('suite.installCommand')}</span>
</div>
<div className="flex min-w-0 items-center gap-2 rounded-lg bg-secondary p-3">
<code className="min-w-0 flex-1 overflow-x-auto text-sm">{command}</code>
<Button
key={item.id}
variant={item.version === suite.version ? 'default' : 'outline'}
variant="outline"
size="sm"
onClick={() => navigate({
to: `/suite/${namespace}/${encodeURIComponent(slug)}`,
search: { version: item.version },
})}
>v{item.version} · {suiteStatusLabel(t, item.status)}</Button>
))}
</div>
</Card>
) : null}
aria-label={t('suite.copyInstallCommand')}
onClick={async () => {
await navigator.clipboard.writeText(command)
toast.success(t('suite.commandCopied'))
}}
><Copy className="h-4 w-4" /></Button>
</div>
</Card>
<SuiteManagementActions suite={suite} />
{versions?.length ? (
<Card className="p-5">
<h2 className="text-sm font-semibold font-heading text-foreground">{t('suite.versionHistory')}</h2>
<div className="mt-4 flex flex-wrap gap-2">
{versions.map((item) => (
<Button
key={item.id}
variant={item.version === suite.version ? 'default' : 'outline'}
size="sm"
onClick={() => navigate({
to: `/suite/${namespace}/${encodeURIComponent(slug)}`,
search: { version: item.version },
})}
>v{item.version} · {suiteStatusLabel(t, item.status)}</Button>
))}
</div>
</Card>
) : null}
<SuiteManagementActions suite={suite} />
</aside>
</div>
</div>
)
}