diff --git a/backend/app/api/skills_marketplace.py b/backend/app/api/skills_marketplace.py index 41f8e652..1828186b 100644 --- a/backend/app/api/skills_marketplace.py +++ b/backend/app/api/skills_marketplace.py @@ -493,6 +493,120 @@ async def _dispatch_gateway_instruction( ) +async def _load_pack_skill_count_by_repo( + *, + session: AsyncSession, + organization_id: UUID, +) -> dict[str, int]: + skills = await MarketplaceSkill.objects.filter_by(organization_id=organization_id).all(session) + return _build_skill_count_by_repo(skills) + + +def _as_skill_pack_read_with_count( + *, + pack: SkillPack, + count_by_repo: dict[str, int], +) -> SkillPackRead: + return _as_skill_pack_read(pack).model_copy( + update={"skill_count": _pack_skill_count(pack=pack, count_by_repo=count_by_repo)}, + ) + + +async def _sync_gateway_installation_state( + *, + session: AsyncSession, + gateway_id: UUID, + skill_id: UUID, + installed: bool, +) -> None: + installation = await GatewayInstalledSkill.objects.filter_by( + gateway_id=gateway_id, + skill_id=skill_id, + ).first(session) + if installed: + if installation is None: + session.add( + GatewayInstalledSkill( + gateway_id=gateway_id, + skill_id=skill_id, + ), + ) + return + + installation.updated_at = utcnow() + session.add(installation) + return + + if installation is not None: + await session.delete(installation) + + +async def _run_marketplace_skill_action( + *, + session: AsyncSession, + ctx: OrganizationContext, + skill_id: UUID, + gateway_id: UUID, + installed: bool, +) -> MarketplaceSkillActionResponse: + gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx) + require_gateway_workspace_root(gateway) + skill = await _require_marketplace_skill_for_org(skill_id=skill_id, session=session, ctx=ctx) + instruction = ( + _install_instruction(skill=skill, gateway=gateway) + if installed + else _uninstall_instruction(skill=skill, gateway=gateway) + ) + try: + await _dispatch_gateway_instruction( + session=session, + gateway=gateway, + message=instruction, + ) + except OpenClawGatewayError as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=str(exc), + ) from exc + + await _sync_gateway_installation_state( + session=session, + gateway_id=gateway.id, + skill_id=skill.id, + installed=installed, + ) + await session.commit() + return MarketplaceSkillActionResponse( + skill_id=skill.id, + gateway_id=gateway.id, + installed=installed, + ) + + +def _apply_pack_candidate_updates( + *, + existing: MarketplaceSkill, + candidate: PackSkillCandidate, +) -> bool: + changed = False + if existing.name != candidate.name: + existing.name = candidate.name + changed = True + if existing.description != candidate.description: + existing.description = candidate.description + changed = True + if existing.category != candidate.category: + existing.category = candidate.category + changed = True + if existing.risk != candidate.risk: + existing.risk = candidate.risk + changed = True + if existing.source != candidate.source: + existing.source = candidate.source + changed = True + return changed + + @router.get("/marketplace", response_model=list[MarketplaceSkillCardRead]) async def list_marketplace_skills( gateway_id: UUID = GATEWAY_ID_QUERY, @@ -580,39 +694,11 @@ async def install_marketplace_skill( ctx: OrganizationContext = ORG_ADMIN_DEP, ) -> MarketplaceSkillActionResponse: """Install a marketplace skill by dispatching instructions to the gateway agent.""" - gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx) - require_gateway_workspace_root(gateway) - skill = await _require_marketplace_skill_for_org(skill_id=skill_id, session=session, ctx=ctx) - try: - await _dispatch_gateway_instruction( - session=session, - gateway=gateway, - message=_install_instruction(skill=skill, gateway=gateway), - ) - except OpenClawGatewayError as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=str(exc), - ) from exc - - installation = await GatewayInstalledSkill.objects.filter_by( - gateway_id=gateway.id, - skill_id=skill.id, - ).first(session) - if installation is None: - session.add( - GatewayInstalledSkill( - gateway_id=gateway.id, - skill_id=skill.id, - ), - ) - else: - installation.updated_at = utcnow() - session.add(installation) - await session.commit() - return MarketplaceSkillActionResponse( - skill_id=skill.id, - gateway_id=gateway.id, + return await _run_marketplace_skill_action( + session=session, + ctx=ctx, + skill_id=skill_id, + gateway_id=gateway_id, installed=True, ) @@ -628,31 +714,11 @@ async def uninstall_marketplace_skill( ctx: OrganizationContext = ORG_ADMIN_DEP, ) -> MarketplaceSkillActionResponse: """Uninstall a marketplace skill by dispatching instructions to the gateway agent.""" - gateway = await _require_gateway_for_org(gateway_id=gateway_id, session=session, ctx=ctx) - require_gateway_workspace_root(gateway) - skill = await _require_marketplace_skill_for_org(skill_id=skill_id, session=session, ctx=ctx) - try: - await _dispatch_gateway_instruction( - session=session, - gateway=gateway, - message=_uninstall_instruction(skill=skill, gateway=gateway), - ) - except OpenClawGatewayError as exc: - raise HTTPException( - status_code=status.HTTP_502_BAD_GATEWAY, - detail=str(exc), - ) from exc - - installation = await GatewayInstalledSkill.objects.filter_by( - gateway_id=gateway.id, - skill_id=skill.id, - ).first(session) - if installation is not None: - await session.delete(installation) - await session.commit() - return MarketplaceSkillActionResponse( - skill_id=skill.id, - gateway_id=gateway.id, + return await _run_marketplace_skill_action( + session=session, + ctx=ctx, + skill_id=skill_id, + gateway_id=gateway_id, installed=False, ) @@ -668,14 +734,12 @@ async def list_skill_packs( .order_by(col(SkillPack.created_at).desc()) .all(session) ) - marketplace_skills = await MarketplaceSkill.objects.filter_by( + count_by_repo = await _load_pack_skill_count_by_repo( + session=session, organization_id=ctx.organization.id, - ).all(session) - count_by_repo = _build_skill_count_by_repo(marketplace_skills) + ) return [ - _as_skill_pack_read(pack).model_copy( - update={"skill_count": _pack_skill_count(pack=pack, count_by_repo=count_by_repo)}, - ) + _as_skill_pack_read_with_count(pack=pack, count_by_repo=count_by_repo) for pack in packs ] @@ -688,13 +752,11 @@ async def get_skill_pack( ) -> SkillPackRead: """Get one skill pack by ID.""" pack = await _require_skill_pack_for_org(pack_id=pack_id, session=session, ctx=ctx) - marketplace_skills = await MarketplaceSkill.objects.filter_by( + count_by_repo = await _load_pack_skill_count_by_repo( + session=session, organization_id=ctx.organization.id, - ).all(session) - count_by_repo = _build_skill_count_by_repo(marketplace_skills) - return _as_skill_pack_read(pack).model_copy( - update={"skill_count": _pack_skill_count(pack=pack, count_by_repo=count_by_repo)}, ) + return _as_skill_pack_read_with_count(pack=pack, count_by_repo=count_by_repo) @router.post("/packs", response_model=SkillPackRead) @@ -722,7 +784,11 @@ async def create_skill_pack( session.add(existing) await session.commit() await session.refresh(existing) - return _as_skill_pack_read(existing) + count_by_repo = await _load_pack_skill_count_by_repo( + session=session, + organization_id=ctx.organization.id, + ) + return _as_skill_pack_read_with_count(pack=existing, count_by_repo=count_by_repo) pack = SkillPack( organization_id=ctx.organization.id, @@ -733,13 +799,11 @@ async def create_skill_pack( session.add(pack) await session.commit() await session.refresh(pack) - marketplace_skills = await MarketplaceSkill.objects.filter_by( + count_by_repo = await _load_pack_skill_count_by_repo( + session=session, organization_id=ctx.organization.id, - ).all(session) - count_by_repo = _build_skill_count_by_repo(marketplace_skills) - return _as_skill_pack_read(pack).model_copy( - update={"skill_count": _pack_skill_count(pack=pack, count_by_repo=count_by_repo)}, ) + return _as_skill_pack_read_with_count(pack=pack, count_by_repo=count_by_repo) @router.patch("/packs/{pack_id}", response_model=SkillPackRead) @@ -770,13 +834,11 @@ async def update_skill_pack( session.add(pack) await session.commit() await session.refresh(pack) - marketplace_skills = await MarketplaceSkill.objects.filter_by( + count_by_repo = await _load_pack_skill_count_by_repo( + session=session, organization_id=ctx.organization.id, - ).all(session) - count_by_repo = _build_skill_count_by_repo(marketplace_skills) - return _as_skill_pack_read(pack).model_copy( - update={"skill_count": _pack_skill_count(pack=pack, count_by_repo=count_by_repo)}, ) + return _as_skill_pack_read_with_count(pack=pack, count_by_repo=count_by_repo) @router.delete("/packs/{pack_id}", response_model=OkResponse) @@ -833,23 +895,7 @@ async def sync_skill_pack( created += 1 continue - changed = False - if existing.name != candidate.name: - existing.name = candidate.name - changed = True - if existing.description != candidate.description: - existing.description = candidate.description - changed = True - if existing.category != candidate.category: - existing.category = candidate.category - changed = True - if existing.risk != candidate.risk: - existing.risk = candidate.risk - changed = True - if existing.source != candidate.source: - existing.source = candidate.source - changed = True - + changed = _apply_pack_candidate_updates(existing=existing, candidate=candidate) if changed: existing.updated_at = utcnow() session.add(existing) diff --git a/backend/migrations/versions/c9d7e9b6a4f2_add_skills_marketplace_tables.py b/backend/migrations/versions/c9d7e9b6a4f2_add_skills_marketplace_tables.py index 0dff61d1..679684ed 100644 --- a/backend/migrations/versions/c9d7e9b6a4f2_add_skills_marketplace_tables.py +++ b/backend/migrations/versions/c9d7e9b6a4f2_add_skills_marketplace_tables.py @@ -38,6 +38,9 @@ def upgrade() -> None: sa.Column("organization_id", sa.Uuid(), nullable=False), sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("category", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("risk", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("source", sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("source_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), @@ -105,8 +108,49 @@ def upgrade() -> None: unique=False, ) + if not _has_table("skill_packs"): + op.create_table( + "skill_packs", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("organization_id", sa.Uuid(), nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("source_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["organization_id"], + ["organizations.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "organization_id", + "source_url", + name="uq_skill_packs_org_source_url", + ), + ) + + skill_packs_org_idx = op.f("ix_skill_packs_organization_id") + if not _has_index("skill_packs", skill_packs_org_idx): + op.create_index( + skill_packs_org_idx, + "skill_packs", + ["organization_id"], + unique=False, + ) + def downgrade() -> None: + skill_packs_org_idx = op.f("ix_skill_packs_organization_id") + if _has_index("skill_packs", skill_packs_org_idx): + op.drop_index( + skill_packs_org_idx, + table_name="skill_packs", + ) + + if _has_table("skill_packs"): + op.drop_table("skill_packs") + gateway_skill_idx = op.f("ix_gateway_installed_skills_skill_id") if _has_index("gateway_installed_skills", gateway_skill_idx): op.drop_index( diff --git a/backend/migrations/versions/d1b2c3e4f5a6_add_skill_packs_table.py b/backend/migrations/versions/d1b2c3e4f5a6_add_skill_packs_table.py deleted file mode 100644 index a80c21de..00000000 --- a/backend/migrations/versions/d1b2c3e4f5a6_add_skill_packs_table.py +++ /dev/null @@ -1,75 +0,0 @@ -"""add skill packs table - -Revision ID: d1b2c3e4f5a6 -Revises: c9d7e9b6a4f2 -Create Date: 2026-02-14 00:00:00.000000 - -""" - -from __future__ import annotations - -import sqlalchemy as sa -import sqlmodel -from alembic import op - -# revision identifiers, used by Alembic. -revision = "d1b2c3e4f5a6" -down_revision = "c9d7e9b6a4f2" -branch_labels = None -depends_on = None - - -def _has_table(table_name: str) -> bool: - return sa.inspect(op.get_bind()).has_table(table_name) - - -def _has_index(table_name: str, index_name: str) -> bool: - if not _has_table(table_name): - return False - indexes = sa.inspect(op.get_bind()).get_indexes(table_name) - return any(index["name"] == index_name for index in indexes) - - -def upgrade() -> None: - if not _has_table("skill_packs"): - op.create_table( - "skill_packs", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("organization_id", sa.Uuid(), nullable=False), - sa.Column("name", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("source_url", sqlmodel.sql.sqltypes.AutoString(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["organization_id"], - ["organizations.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "organization_id", - "source_url", - name="uq_skill_packs_org_source_url", - ), - ) - - org_idx = op.f("ix_skill_packs_organization_id") - if not _has_index("skill_packs", org_idx): - op.create_index( - org_idx, - "skill_packs", - ["organization_id"], - unique=False, - ) - - -def downgrade() -> None: - org_idx = op.f("ix_skill_packs_organization_id") - if _has_index("skill_packs", org_idx): - op.drop_index( - org_idx, - table_name="skill_packs", - ) - - if _has_table("skill_packs"): - op.drop_table("skill_packs") diff --git a/backend/migrations/versions/e7a9b1c2d3e4_add_marketplace_skill_metadata_fields.py b/backend/migrations/versions/e7a9b1c2d3e4_add_marketplace_skill_metadata_fields.py deleted file mode 100644 index 9ee9b697..00000000 --- a/backend/migrations/versions/e7a9b1c2d3e4_add_marketplace_skill_metadata_fields.py +++ /dev/null @@ -1,57 +0,0 @@ -"""add marketplace skill metadata fields - -Revision ID: e7a9b1c2d3e4 -Revises: d1b2c3e4f5a6 -Create Date: 2026-02-14 00:00:01.000000 - -""" - -from __future__ import annotations - -import sqlalchemy as sa -import sqlmodel -from alembic import op - -# revision identifiers, used by Alembic. -revision = "e7a9b1c2d3e4" -down_revision = "d1b2c3e4f5a6" -branch_labels = None -depends_on = None - - -def _has_table(table_name: str) -> bool: - return sa.inspect(op.get_bind()).has_table(table_name) - - -def _has_column(table_name: str, column_name: str) -> bool: - if not _has_table(table_name): - return False - columns = sa.inspect(op.get_bind()).get_columns(table_name) - return any(column["name"] == column_name for column in columns) - - -def upgrade() -> None: - if not _has_column("marketplace_skills", "category"): - op.add_column( - "marketplace_skills", - sa.Column("category", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - ) - if not _has_column("marketplace_skills", "risk"): - op.add_column( - "marketplace_skills", - sa.Column("risk", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - ) - if not _has_column("marketplace_skills", "source"): - op.add_column( - "marketplace_skills", - sa.Column("source", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - ) - - -def downgrade() -> None: - if _has_column("marketplace_skills", "source"): - op.drop_column("marketplace_skills", "source") - if _has_column("marketplace_skills", "risk"): - op.drop_column("marketplace_skills", "risk") - if _has_column("marketplace_skills", "category"): - op.drop_column("marketplace_skills", "category") diff --git a/frontend/src/app/skills/marketplace/page.tsx b/frontend/src/app/skills/marketplace/page.tsx index e2e8e31e..64cbda08 100644 --- a/frontend/src/app/skills/marketplace/page.tsx +++ b/frontend/src/app/skills/marketplace/page.tsx @@ -3,7 +3,7 @@ export const dynamic = "force-dynamic"; import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "next/navigation"; import { useAuth } from "@/auth/clerk"; @@ -26,18 +26,15 @@ import { type listSkillPacksApiV1SkillsPacksGetResponse, useListSkillPacksApiV1SkillsPacksGet, } from "@/api/generated/skills/skills"; +import { SkillInstallDialog } from "@/components/skills/SkillInstallDialog"; import { MarketplaceSkillsTable } from "@/components/skills/MarketplaceSkillsTable"; import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; -import { Button, buttonVariants } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { buttonVariants } from "@/components/ui/button"; import { useOrganizationMembership } from "@/lib/use-organization-membership"; +import { + normalizeRepoSourceUrl, + repoBaseFromSkillSourceUrl, +} from "@/lib/skills-source"; import { useUrlSorting } from "@/lib/use-url-sorting"; const MARKETPLACE_SKILLS_SORTABLE_COLUMNS = [ @@ -48,25 +45,6 @@ const MARKETPLACE_SKILLS_SORTABLE_COLUMNS = [ "updated_at", ]; -const normalizeRepoSourceUrl = (sourceUrl: string): string => { - const trimmed = sourceUrl.trim().replace(/\/+$/, ""); - return trimmed.endsWith(".git") ? trimmed.slice(0, -4) : trimmed; -}; - -const repoBaseFromSkillSourceUrl = (skillSourceUrl: string): string | null => { - try { - const parsed = new URL(skillSourceUrl); - const marker = "/tree/"; - const markerIndex = parsed.pathname.indexOf(marker); - if (markerIndex <= 0) return null; - return normalizeRepoSourceUrl( - `${parsed.origin}${parsed.pathname.slice(0, markerIndex)}`, - ); - } catch { - return null; - } -}; - export default function SkillsMarketplacePage() { const queryClient = useQueryClient(); const searchParams = useSearchParams(); @@ -159,6 +137,51 @@ export default function SkillsMarketplacePage() { }); }, [selectedPack, skills]); + const loadSkillsByGateway = useCallback( + async () => + Promise.all( + gateways.map(async (gateway) => { + const response = await listMarketplaceSkillsApiV1SkillsMarketplaceGet({ + gateway_id: gateway.id, + }); + return { + gatewayId: gateway.id, + gatewayName: gateway.name, + skills: response.status === 200 ? response.data : [], + }; + }), + ), + [gateways], + ); + + const updateInstalledGatewayNames = useCallback( + ({ + skillId, + gatewayName, + installed, + }: { + skillId: string; + gatewayName: string; + installed: boolean; + }) => { + setInstalledGatewayNamesBySkillId((previous) => { + const installedOn = previous[skillId] ?? []; + if (installed) { + if (installedOn.includes(gatewayName)) return previous; + return { + ...previous, + [skillId]: [...installedOn, gatewayName], + }; + } + return { + ...previous, + [skillId]: installedOn.filter((name) => name !== gatewayName), + }; + }); + }, + [], + ); + useEffect(() => { let cancelled = false; @@ -169,14 +192,7 @@ export default function SkillsMarketplacePage() { } try { - const responses = await Promise.all( - gateways.map(async (gateway) => { - const response = await listMarketplaceSkillsApiV1SkillsMarketplaceGet({ - gateway_id: gateway.id, - }); - return { gatewayName: gateway.name, response }; - }), - ); + const gatewaySkills = await loadSkillsByGateway(); if (cancelled) return; @@ -185,9 +201,8 @@ export default function SkillsMarketplacePage() { nextInstalledGatewayNamesBySkillId[skill.id] = []; } - for (const { gatewayName, response } of responses) { - if (response.status !== 200) continue; - for (const skill of response.data) { + for (const { gatewayName, skills: gatewaySkillRows } of gatewaySkills) { + for (const skill of gatewaySkillRows) { if (!skill.installed) continue; if (!nextInstalledGatewayNamesBySkillId[skill.id]) continue; nextInstalledGatewayNamesBySkillId[skill.id].push(gatewayName); @@ -206,7 +221,7 @@ export default function SkillsMarketplacePage() { return () => { cancelled = true; }; - }, [gateways, isAdmin, isSignedIn, skills]); + }, [gateways, isAdmin, isSignedIn, loadSkillsByGateway, skills]); const installMutation = useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost( @@ -223,13 +238,10 @@ export default function SkillsMarketplacePage() { const gatewayName = gateways.find((gateway) => gateway.id === variables.params.gateway_id)?.name; if (gatewayName) { - setInstalledGatewayNamesBySkillId((previous) => { - const installedOn = previous[variables.skillId] ?? []; - if (installedOn.includes(gatewayName)) return previous; - return { - ...previous, - [variables.skillId]: [...installedOn, gatewayName], - }; + updateInstalledGatewayNames({ + skillId: variables.skillId, + gatewayName, + installed: true, }); } }, @@ -253,12 +265,10 @@ export default function SkillsMarketplacePage() { const gatewayName = gateways.find((gateway) => gateway.id === variables.params.gateway_id)?.name; if (gatewayName) { - setInstalledGatewayNamesBySkillId((previous) => { - const installedOn = previous[variables.skillId] ?? []; - return { - ...previous, - [variables.skillId]: installedOn.filter((name) => name !== gatewayName), - }; + updateInstalledGatewayNames({ + skillId: variables.skillId, + gatewayName, + installed: false, }); } }, @@ -288,18 +298,11 @@ export default function SkillsMarketplacePage() { setIsGatewayStatusLoading(true); setGatewayStatusError(null); try { - const entries = await Promise.all( - gateways.map(async (gateway) => { - const response = await listMarketplaceSkillsApiV1SkillsMarketplaceGet({ - gateway_id: gateway.id, - }); - const row = - response.status === 200 - ? response.data.find((skill) => skill.id === selectedSkill.id) - : null; - return [gateway.id, Boolean(row?.installed)] as const; - }), - ); + const gatewaySkills = await loadSkillsByGateway(); + const entries = gatewaySkills.map(({ gatewayId, skills: gatewaySkillRows }) => { + const row = gatewaySkillRows.find((skill) => skill.id === selectedSkill.id); + return [gatewayId, Boolean(row?.installed)] as const; + }); if (cancelled) return; setGatewayInstalledById(Object.fromEntries(entries)); } catch (error) { @@ -319,10 +322,10 @@ export default function SkillsMarketplacePage() { return () => { cancelled = true; }; - }, [gateways, selectedSkill]); + }, [gateways, loadSkillsByGateway, selectedSkill]); const mutationError = - installMutation.error?.message ?? uninstallMutation.error?.message; + installMutation.error?.message ?? uninstallMutation.error?.message ?? null; const isMutating = installMutation.isPending || uninstallMutation.isPending; @@ -407,92 +410,34 @@ export default function SkillsMarketplacePage() { )} - {skillsQuery.error ? ( -

{skillsQuery.error.message}

- ) : null} - {packsQuery.error ? ( -

{packsQuery.error.message}

- ) : null} - {mutationError ?

{mutationError}

: null} - + {skillsQuery.error ? ( +

{skillsQuery.error.message}

+ ) : null} + {packsQuery.error ? ( +

{packsQuery.error.message}

+ ) : null} + {mutationError ?

{mutationError}

: null} + - { if (!open) { setSelectedSkill(null); } }} - > - - - {selectedSkill ? selectedSkill.name : "Install skill"} - - Choose one or more gateways where this skill should be installed. - - - -
- {isGatewayStatusLoading ? ( -

Loading gateways...

- ) : ( - gateways.map((gateway) => { - const isInstalled = gatewayInstalledById[gateway.id] === true; - const isUpdatingGateway = - installingGatewayId === gateway.id && - (installMutation.isPending || uninstallMutation.isPending); - return ( -
-
-

{gateway.name}

-
- -
- ); - }) - )} - {gatewayStatusError ? ( -

{gatewayStatusError}

- ) : null} - {mutationError ? ( -

{mutationError}

- ) : null} -
- - - - -
-
+ onToggleInstall={(gatewayId, isInstalled) => { + void handleGatewayInstallAction(gatewayId, isInstalled); + }} + /> ); } diff --git a/frontend/src/app/skills/packs/page.tsx b/frontend/src/app/skills/packs/page.tsx index 4bafcd07..8b34ce31 100644 --- a/frontend/src/app/skills/packs/page.tsx +++ b/frontend/src/app/skills/packs/page.tsx @@ -90,6 +90,25 @@ export default function SkillsPacksPage() { deleteMutation.mutate({ packId: deleteTarget.id }); }; + const handleSyncPack = async (pack: SkillPackRead) => { + setSyncingPackIds((previous) => { + const next = new Set(previous); + next.add(pack.id); + return next; + }); + try { + await syncMutation.mutateAsync({ + packId: pack.id, + }); + } finally { + setSyncingPackIds((previous) => { + const next = new Set(previous); + next.delete(pack.id); + return next; + }); + } + }; + return ( <> { - void (async () => { - setSyncingPackIds((previous) => { - const next = new Set(previous); - next.add(pack.id); - return next; - }); - try { - await syncMutation.mutateAsync({ - packId: pack.id, - }); - } finally { - setSyncingPackIds((previous) => { - const next = new Set(previous); - next.delete(pack.id); - return next; - }); - } - })(); + void handleSyncPack(pack); }} onDelete={setDeleteTarget} emptyState={{ diff --git a/frontend/src/components/skills/MarketplaceSkillsTable.tsx b/frontend/src/components/skills/MarketplaceSkillsTable.tsx index c345531d..a90a6b95 100644 --- a/frontend/src/components/skills/MarketplaceSkillsTable.tsx +++ b/frontend/src/components/skills/MarketplaceSkillsTable.tsx @@ -1,11 +1,10 @@ -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import Link from "next/link"; import { type ColumnDef, type OnChangeFn, type SortingState, - type Updater, getCoreRowModel, getSortedRowModel, useReactTable, @@ -15,7 +14,16 @@ import type { MarketplaceSkillCardRead } from "@/api/generated/model"; import { DataTable, type DataTableEmptyState } from "@/components/tables/DataTable"; import { dateCell } from "@/components/tables/cell-formatters"; import { Button, buttonVariants } from "@/components/ui/button"; +import { + SKILLS_TABLE_EMPTY_ICON, + useTableSortingState, +} from "@/components/skills/table-helpers"; import { truncateText as truncate } from "@/lib/formatters"; +import { + packLabelFromUrl, + packUrlFromSkillSourceUrl, + packsHrefFromPackUrl, +} from "@/lib/skills-source"; type MarketplaceSkillsTableProps = { skills: MarketplaceSkillCardRead[]; @@ -34,57 +42,6 @@ type MarketplaceSkillsTableProps = { }; }; -const DEFAULT_EMPTY_ICON = ( - - - - - - - -); - -const toPackUrl = (sourceUrl: string): string => { - try { - const parsed = new URL(sourceUrl); - const treeMarker = "/tree/"; - const markerIndex = parsed.pathname.indexOf(treeMarker); - if (markerIndex > 0) { - const repoPath = parsed.pathname.slice(0, markerIndex); - return `${parsed.origin}${repoPath}`; - } - return sourceUrl; - } catch { - return sourceUrl; - } -}; - -const toPackLabel = (packUrl: string): string => { - try { - const parsed = new URL(packUrl); - const segments = parsed.pathname.split("/").filter(Boolean); - if (segments.length >= 2) { - return `${segments[0]}/${segments[1]}`; - } - return parsed.host; - } catch { - return "Open pack"; - } -}; - -const toPacksHref = (packUrl: string): string => { - const params = new URLSearchParams({ source_url: packUrl }); - return `/skills/packs?${params.toString()}`; -}; - export function MarketplaceSkillsTable({ skills, installedGatewayNamesBySkillId, @@ -99,15 +56,11 @@ export function MarketplaceSkillsTable({ getEditHref, emptyState, }: MarketplaceSkillsTableProps) { - const [internalSorting, setInternalSorting] = useState([ - { id: "name", desc: false }, - ]); - const resolvedSorting = sorting ?? internalSorting; - const handleSortingChange: OnChangeFn = - onSortingChange ?? - ((updater: Updater) => { - setInternalSorting(updater); - }); + const { resolvedSorting, handleSortingChange } = useTableSortingState( + sorting, + onSortingChange, + [{ id: "name", desc: false }], + ); const columns = useMemo[]>(() => { const baseColumns: ColumnDef[] = [ @@ -140,13 +93,13 @@ export function MarketplaceSkillsTable({ accessorKey: "source_url", header: "Pack", cell: ({ row }) => { - const packUrl = toPackUrl(row.original.source_url); + const packUrl = packUrlFromSkillSourceUrl(row.original.source_url); return ( - {truncate(toPackLabel(packUrl), 40)} + {truncate(packLabelFromUrl(packUrl), 40)} ); }, @@ -271,7 +224,7 @@ export function MarketplaceSkillsTable({ emptyState={ emptyState ? { - icon: emptyState.icon ?? DEFAULT_EMPTY_ICON, + icon: emptyState.icon ?? SKILLS_TABLE_EMPTY_ICON, title: emptyState.title, description: emptyState.description, actionHref: emptyState.actionHref, diff --git a/frontend/src/components/skills/SkillInstallDialog.tsx b/frontend/src/components/skills/SkillInstallDialog.tsx new file mode 100644 index 00000000..5f97c47a --- /dev/null +++ b/frontend/src/components/skills/SkillInstallDialog.tsx @@ -0,0 +1,105 @@ +"use client"; + +import type { MarketplaceSkillCardRead } from "@/api/generated/model"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +type GatewaySummary = { + id: string; + name: string; +}; + +type SkillInstallDialogProps = { + selectedSkill: MarketplaceSkillCardRead | null; + gateways: GatewaySummary[]; + gatewayInstalledById: Record; + isGatewayStatusLoading: boolean; + installingGatewayId: string | null; + isMutating: boolean; + gatewayStatusError: string | null; + mutationError: string | null; + onOpenChange: (open: boolean) => void; + onToggleInstall: (gatewayId: string, isInstalled: boolean) => void; +}; + +export function SkillInstallDialog({ + selectedSkill, + gateways, + gatewayInstalledById, + isGatewayStatusLoading, + installingGatewayId, + isMutating, + gatewayStatusError, + mutationError, + onOpenChange, + onToggleInstall, +}: SkillInstallDialogProps) { + return ( + + + + {selectedSkill ? selectedSkill.name : "Install skill"} + + Choose one or more gateways where this skill should be installed. + + + +
+ {isGatewayStatusLoading ? ( +

Loading gateways...

+ ) : ( + gateways.map((gateway) => { + const isInstalled = gatewayInstalledById[gateway.id] === true; + const isUpdatingGateway = installingGatewayId === gateway.id && isMutating; + return ( +
+
+

{gateway.name}

+
+ +
+ ); + }) + )} + {gatewayStatusError ? ( +

{gatewayStatusError}

+ ) : null} + {mutationError ?

{mutationError}

: null} +
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/skills/SkillPacksTable.tsx b/frontend/src/components/skills/SkillPacksTable.tsx index 2948c4eb..d8e8ed79 100644 --- a/frontend/src/components/skills/SkillPacksTable.tsx +++ b/frontend/src/components/skills/SkillPacksTable.tsx @@ -1,11 +1,10 @@ -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import Link from "next/link"; import { type ColumnDef, type OnChangeFn, type SortingState, - type Updater, getCoreRowModel, getSortedRowModel, useReactTable, @@ -15,6 +14,10 @@ import type { SkillPackRead } from "@/api/generated/model"; import { DataTable, type DataTableEmptyState } from "@/components/tables/DataTable"; import { dateCell } from "@/components/tables/cell-formatters"; import { Button } from "@/components/ui/button"; +import { + SKILLS_TABLE_EMPTY_ICON, + useTableSortingState, +} from "@/components/skills/table-helpers"; import { truncateText as truncate } from "@/lib/formatters"; type SkillPacksTableProps = { @@ -33,24 +36,6 @@ type SkillPacksTableProps = { }; }; -const DEFAULT_EMPTY_ICON = ( - - - - - - - -); - export function SkillPacksTable({ packs, isLoading = false, @@ -64,15 +49,11 @@ export function SkillPacksTable({ getEditHref, emptyState, }: SkillPacksTableProps) { - const [internalSorting, setInternalSorting] = useState([ - { id: "name", desc: false }, - ]); - const resolvedSorting = sorting ?? internalSorting; - const handleSortingChange: OnChangeFn = - onSortingChange ?? - ((updater: Updater) => { - setInternalSorting(updater); - }); + const { resolvedSorting, handleSortingChange } = useTableSortingState( + sorting, + onSortingChange, + [{ id: "name", desc: false }], + ); const columns = useMemo[]>(() => { const baseColumns: ColumnDef[] = [ @@ -175,7 +156,7 @@ export function SkillPacksTable({ emptyState={ emptyState ? { - icon: emptyState.icon ?? DEFAULT_EMPTY_ICON, + icon: emptyState.icon ?? SKILLS_TABLE_EMPTY_ICON, title: emptyState.title, description: emptyState.description, actionHref: emptyState.actionHref, diff --git a/frontend/src/components/skills/table-helpers.tsx b/frontend/src/components/skills/table-helpers.tsx new file mode 100644 index 00000000..ea607de4 --- /dev/null +++ b/frontend/src/components/skills/table-helpers.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState } from "react"; + +import { + type OnChangeFn, + type SortingState, + type Updater, +} from "@tanstack/react-table"; + +export const SKILLS_TABLE_EMPTY_ICON = ( + + + + + + + +); + +export const useTableSortingState = ( + sorting: SortingState | undefined, + onSortingChange: OnChangeFn | undefined, + defaultSorting: SortingState, +): { + resolvedSorting: SortingState; + handleSortingChange: OnChangeFn; +} => { + const [internalSorting, setInternalSorting] = useState(defaultSorting); + const resolvedSorting = sorting ?? internalSorting; + const handleSortingChange: OnChangeFn = + onSortingChange ?? + ((updater: Updater) => { + setInternalSorting(updater); + }); + + return { + resolvedSorting, + handleSortingChange, + }; +}; diff --git a/frontend/src/lib/skills-source.ts b/frontend/src/lib/skills-source.ts new file mode 100644 index 00000000..982f5ba4 --- /dev/null +++ b/frontend/src/lib/skills-source.ts @@ -0,0 +1,39 @@ +export const normalizeRepoSourceUrl = (sourceUrl: string): string => { + const trimmed = sourceUrl.trim().replace(/\/+$/, ""); + return trimmed.endsWith(".git") ? trimmed.slice(0, -4) : trimmed; +}; + +export const repoBaseFromSkillSourceUrl = (skillSourceUrl: string): string | null => { + try { + const parsed = new URL(skillSourceUrl); + const marker = "/tree/"; + const markerIndex = parsed.pathname.indexOf(marker); + if (markerIndex <= 0) return null; + return normalizeRepoSourceUrl(`${parsed.origin}${parsed.pathname.slice(0, markerIndex)}`); + } catch { + return null; + } +}; + +export const packUrlFromSkillSourceUrl = (skillSourceUrl: string): string => { + const repoBase = repoBaseFromSkillSourceUrl(skillSourceUrl); + return repoBase ?? skillSourceUrl; +}; + +export const packLabelFromUrl = (packUrl: string): string => { + try { + const parsed = new URL(packUrl); + const segments = parsed.pathname.split("/").filter(Boolean); + if (segments.length >= 2) { + return `${segments[0]}/${segments[1]}`; + } + return parsed.host; + } catch { + return "Open pack"; + } +}; + +export const packsHrefFromPackUrl = (packUrl: string): string => { + const params = new URLSearchParams({ source_url: packUrl }); + return `/skills/packs?${params.toString()}`; +};