From e121477c0e334f0a76e35a6c3a4085d89321a276 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Thu, 2 Jul 2026 20:10:11 +0800 Subject: [PATCH 1/3] fix(skillhub): disable installed remote skill installs --- web/app/src/api/skills.ts | 17 +--- .../workspace/useWorkspaceHubSelection.ts | 20 +++- web/app/src/models/skillhub.ts | 23 +++++ .../WorkspaceSidebar/SkillUploadDialog.tsx | 69 +++++++------ .../WorkspaceSidebar/WorkspaceSidebar.css | 18 ++++ .../WorkspaceSidebar/WorkspaceTabPanels.tsx | 9 +- web/app/src/shared/i18n/messages.ts | 5 +- .../components/WorkspaceTabPanels.test.tsx | 65 ++++++++++++ .../hooks/useWorkspaceHubSelection.test.tsx | 99 +++++++++++++++++++ web/app/tests/models/skillhub.test.ts | 30 +++++- 10 files changed, 302 insertions(+), 53 deletions(-) create mode 100644 web/app/tests/hooks/useWorkspaceHubSelection.test.tsx diff --git a/web/app/src/api/skills.ts b/web/app/src/api/skills.ts index ada85b16..7e2207c0 100644 --- a/web/app/src/api/skills.ts +++ b/web/app/src/api/skills.ts @@ -1,7 +1,7 @@ import { del, get, request } from "@/api/client"; import { fetchServerConfig } from "@/api/config"; import { normalizeConfigSettings } from "@/models/configSettings"; -import { SKILL_SOURCE_OFFICIAL } from "@/models/skillhub"; +import { SKILL_SOURCE_OFFICIAL, skillNameFromRemotePath } from "@/models/skillhub"; import type { SkillFile, SkillSummary, SkillTree } from "@/models/skillhub"; const SKILLS_PATH = "api/v1/skills"; @@ -134,7 +134,8 @@ function normalizeAgenticHubSkill(record: unknown, source: string): SkillSummary return null; } const values = record as Record; - const name = stringFromUnknown(values.name) || stringFromUnknown(values.nickname) || skillNameFromPath(values.path); + const name = + stringFromUnknown(values.name) || stringFromUnknown(values.nickname) || skillNameFromRemotePath(values.path); const remotePath = stringFromUnknown(values.path); if (!name) { return null; @@ -152,18 +153,6 @@ function normalizeAgenticHubSkill(record: unknown, source: string): SkillSummary }; } -function skillNameFromPath(value: unknown): string { - const path = stringFromUnknown(value); - if (!path) { - return ""; - } - const parts = path - .split("/") - .map((part) => part.trim()) - .filter(Boolean); - return parts.at(-1) || ""; -} - function stringFromUnknown(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } diff --git a/web/app/src/hooks/workspace/useWorkspaceHubSelection.ts b/web/app/src/hooks/workspace/useWorkspaceHubSelection.ts index 1567b9d7..258cf9d7 100644 --- a/web/app/src/hooks/workspace/useWorkspaceHubSelection.ts +++ b/web/app/src/hooks/workspace/useWorkspaceHubSelection.ts @@ -136,7 +136,10 @@ export function useWorkspaceHubSelection({ const refetchHubTemplateDetail = hubTemplateDetailQuery.refetch; const refetchHubWorkspace = hubWorkspaceQuery.refetch; const refetchSkills = skillsQuery.refetch; - const refetchRemoteSkills = officialSkillsQuery.refetch; + const refetchOfficialSkills = officialSkillsQuery.refetch; + const refetchRemoteSkills = useCallback(async () => { + await Promise.all([refetchSkills(), refetchOfficialSkills()]); + }, [refetchOfficialSkills, refetchSkills]); const refetchSkillTree = skillTreeQuery.refetch; const loadMoreRemoteSkills = useCallback(async () => { if (!remoteSkillsEnabled || !officialSkillsQuery.hasNextPage || officialSkillsQuery.isFetchingNextPage) { @@ -145,6 +148,13 @@ export function useWorkspaceHubSelection({ await officialSkillsQuery.fetchNextPage(); }, [officialSkillsQuery, remoteSkillsEnabled]); + useEffect(() => { + if (!remoteSkillsEnabled) { + return; + } + void refetchSkills(); + }, [refetchSkills, remoteSkillsEnabled]); + const selectedHubTemplateView = hubTemplateDetailQuery.data?.id === selectedHubTemplateId ? hubTemplateDetailQuery.data : selectedHubTemplate; const workspaceListings = useMemo( @@ -234,8 +244,12 @@ export function useWorkspaceHubSelection({ remoteSkillsEnabled && officialSkillsQuery.error ? errorMessage(officialSkillsQuery.error, t("resourcesSkillRemoteSkillsLoadFailed")) : ""; - const skillTreeError = skillTreeQuery.error ? errorMessage(skillTreeQuery.error, t("resourcesSkillFilesLoadFailed")) : ""; - const skillFileError = skillFileQuery.error ? errorMessage(skillFileQuery.error, t("resourcesSkillFileLoadFailed")) : ""; + const skillTreeError = skillTreeQuery.error + ? errorMessage(skillTreeQuery.error, t("resourcesSkillFilesLoadFailed")) + : ""; + const skillFileError = skillFileQuery.error + ? errorMessage(skillFileQuery.error, t("resourcesSkillFileLoadFailed")) + : ""; const retry = useCallback(async () => { if (refreshTemplates) { diff --git a/web/app/src/models/skillhub.ts b/web/app/src/models/skillhub.ts index 0c71a53c..444457db 100644 --- a/web/app/src/models/skillhub.ts +++ b/web/app/src/models/skillhub.ts @@ -61,6 +61,29 @@ export function hasSkillName( return (skills || []).some((item) => normalizeSkillName(item?.name) === value); } +export function hasInstalledRemoteSkill( + installedSkills: readonly SkillSummary[] | null | undefined, + remoteSkill: SkillSummary | null | undefined, +): boolean { + const installedName = remoteSkillInstalledName(remoteSkill); + return hasSkillName(installedSkills, installedName); +} + +function remoteSkillInstalledName(skill: SkillSummary | null | undefined): string { + const remotePathName = skillNameFromRemotePath(skill?.remotePath); + return remotePathName || normalizeSkillName(skill?.name); +} + +export function skillNameFromRemotePath(value: unknown): string { + return ( + String(value || "") + .split("/") + .map((part) => part.trim()) + .filter(Boolean) + .at(-1) || "" + ); +} + function normalizeSkillName(value: unknown): string { return String(value || "").trim(); } diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/SkillUploadDialog.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/SkillUploadDialog.tsx index c7a16fc6..54852751 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/SkillUploadDialog.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/SkillUploadDialog.tsx @@ -12,12 +12,14 @@ import { TextInput, } from "@/components/ui"; import type { LocaleCode, TranslateFn } from "@/models/conversations"; +import { hasInstalledRemoteSkill } from "@/models/skillhub"; import type { SkillSummary } from "@/models/skillhub"; import { localizeTemplateSourceTag } from "@/shared/i18n"; export type SkillUploadDialogProps = { busy: boolean; error: string; + installedSkills: readonly SkillSummary[]; locale: LocaleCode; onInstallRemoteSkill?: (skill: SkillSummary) => Promise; onLoadMoreRemoteSkills?: () => Promise; @@ -51,6 +53,7 @@ export function SkillUploadDialog({ onSubmit, busy, error, + installedSkills, locale, remoteInstallBusy, remoteInstallError, @@ -131,7 +134,7 @@ export function SkillUploadDialog({ const handleRemoteInstall = useCallback( async (skill: SkillSummary) => { - if (!onInstallRemoteSkill) { + if (!onInstallRemoteSkill || hasInstalledRemoteSkill(installedSkills, skill)) { return; } const result = await onInstallRemoteSkill(skill); @@ -139,7 +142,7 @@ export function SkillUploadDialog({ onOpenChange(false); } }, - [onInstallRemoteSkill, onOpenChange], + [installedSkills, onInstallRemoteSkill, onOpenChange], ); const handleRemoteListScroll = useCallback( @@ -255,32 +258,42 @@ export function SkillUploadDialog({ ) : remoteSkills.length ? ( <>
- {remoteSkills.map((item) => ( -
- - - {item.name} - {item.description || item.remotePath} - - - - {localizeTemplateSourceTag("official", locale)} - - -
- ))} + {remoteSkills.map((item) => { + const remoteKey = item.remotePath || item.name; + const installed = hasInstalledRemoteSkill(installedSkills, item); + const installing = remoteInstallBusy === remoteKey; + return ( +
+ + + {item.name} + + {item.description || item.remotePath} + + + + + {localizeTemplateSourceTag("official", locale)} + + +
+ ); + })} {remoteSkillsLoadingMore ? (
{t("resourcesSkillRemoteSkillsLoading")}
) : null} diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.css b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.css index 2b70d8bc..bc976322 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.css +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceSidebar.css @@ -229,6 +229,24 @@ text-align: center; } +.hub-skill-remote-install-button { + width: 128px; + min-width: 128px; + flex-shrink: 0; +} + +.hub-skill-remote-install-button.installed:disabled { + border-color: var(--gray-300); + background: var(--gray-100); + color: var(--gray-600); +} + +:root[data-theme="dark"] .hub-skill-remote-install-button.installed:disabled { + border-color: color-mix(in srgb, var(--gray-300) 18%, transparent); + background: color-mix(in srgb, var(--gray-300) 10%, transparent); + color: var(--gray-300); +} + @media (max-width: 560px) { .hub-skill-remote-row { grid-template-columns: auto minmax(0, 1fr) auto; diff --git a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceTabPanels.tsx b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceTabPanels.tsx index 53afd3ff..8b0afc9f 100644 --- a/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceTabPanels.tsx +++ b/web/app/src/pages/WorkspacePage/components/WorkspaceSidebar/WorkspaceTabPanels.tsx @@ -692,9 +692,7 @@ export function WorkspaceTabPanels({
); diff --git a/web/app/tests/api/skills.test.ts b/web/app/tests/api/skills.test.ts index c69795dc..d3ead6e8 100644 --- a/web/app/tests/api/skills.test.ts +++ b/web/app/tests/api/skills.test.ts @@ -63,7 +63,6 @@ describe("skills API", () => { name: "agent-builder", readonly: true, remoteRef: "dev", - remoteSource: "https://opencsg-stg.example.test", remotePath: "AIWizards/agent-builder", source: "official", }, @@ -107,7 +106,6 @@ describe("skills API", () => { items: [ { name: "page-two-skill", - remoteSource: "https://opencsg-stg.example.test", remotePath: "AIWizards/page-two-skill", source: "official", }, diff --git a/web/app/tests/components/WorkspaceTabPanels.test.tsx b/web/app/tests/components/WorkspaceTabPanels.test.tsx index fe299873..dfeff6e2 100644 --- a/web/app/tests/components/WorkspaceTabPanels.test.tsx +++ b/web/app/tests/components/WorkspaceTabPanels.test.tsx @@ -64,14 +64,7 @@ const hub: WorkspaceSidebarProps["hub"] = { selectedHubTemplateId: "", selectedHubResourceType: "skill", selectedHubSkillName: "demo-skill", - remoteSkills: [ - { - name: "agent-builder", - description: "Build agents", - remoteSource: "https://opencsg-stg.example.test", - remotePath: "AIWizards/agent-builder", - }, - ], + remoteSkills: [{ name: "agent-builder", description: "Build agents", remotePath: "AIWizards/agent-builder" }], remoteSkillsError: "", remoteSkillsLoading: false, skills: [{ name: "demo-skill", description: "Demo skill" }], @@ -420,14 +413,7 @@ describe("WorkspaceTabPanels", () => { remoteSkillsHasMore: false, remoteSkillsLoadingMore: false, remoteSkillsSearch: "", - skills: [ - { - name: "agent-builder", - description: "Already installed", - remoteSource: "https://opencsg-stg.example.test", - remotePath: "AIWizards/agent-builder", - }, - ], + skills: [{ name: "agent-builder", description: "Already installed" }], } as unknown as WorkspaceSidebarProps["hub"]; render( @@ -475,73 +461,9 @@ describe("WorkspaceTabPanels", () => { fireEvent.click(screen.getByRole("button", { name: "Upload skill" })); fireEvent.click(screen.getByRole("tab", { name: /Remote install/ })); - const installedButton = screen.getByRole("button", { name: "Installed" }); + const installedButton = screen.getByRole("button", { name: "Install" }); expect(installedButton).toBeDisabled(); fireEvent.click(installedButton); expect(installRemoteSkill).not.toHaveBeenCalled(); }); - - it("keeps remote skill install enabled for same-name local skills without remote identity", () => { - const installRemoteSkill = vi.fn(async () => ({ name: "agent-builder" })); - const remoteHub = { - ...hub, - installRemoteSkill, - remoteInstallBusy: "", - remoteInstallError: "", - remoteSkillsHasMore: false, - remoteSkillsLoadingMore: false, - remoteSkillsSearch: "", - skills: [{ name: "agent-builder", description: "Local skill with same name" }], - } as unknown as WorkspaceSidebarProps["hub"]; - - render( - {}} - onCreateNotificationParticipant={() => {}} - onCreateRoom={() => {}} - onOpenCreateTask={() => {}} - onOpenCreateTeam={() => {}} - onPreviewAgent={() => {}} - onPreviewUser={() => {}} - onSelectAgent={() => {}} - onSelectComputer={() => {}} - onSelectConversation={() => {}} - onSelectHuman={() => {}} - onSelectHubSkill={() => {}} - onSelectHubTemplate={() => {}} - onSelectTask={() => {}} - onSelectTeam={() => {}} - onSelectThread={() => {}} - onToggleWorkspaceGroup={() => {}} - onViewTaskDetails={() => {}} - t={t} - taskCount={0} - taskItems={[]} - teams={[]} - threadGroups={[]} - usersById={new Map()} - workerAgentItems={[managerAgent]} - workspaceTab={WorkspaceTabs.hub} - />, - ); - - fireEvent.click(screen.getByRole("button", { name: "Upload skill" })); - fireEvent.click(screen.getByRole("tab", { name: /Remote install/ })); - - const installButton = screen.getByRole("button", { name: "Install" }); - expect(installButton).toBeEnabled(); - fireEvent.click(installButton); - expect(installRemoteSkill).toHaveBeenCalledWith(expect.objectContaining({ remotePath: "AIWizards/agent-builder" })); - }); }); diff --git a/web/app/tests/models/skillhub.test.ts b/web/app/tests/models/skillhub.test.ts index ae474437..4e26ef1a 100644 --- a/web/app/tests/models/skillhub.test.ts +++ b/web/app/tests/models/skillhub.test.ts @@ -25,41 +25,19 @@ describe("skill hub helpers", () => { expect(skillSourceBadgeName(null)).toBe(""); }); - it("matches installed remote skills by remote identity instead of local name", () => { - const installedSkills = [ - { - name: "agent-builder", - remoteSource: "https://opencsg-stg.example.test", - remotePath: "AIWizards/agent-builder", - }, - { name: "local-only" }, - ]; + it("matches installed remote skills by the remote path skill name", () => { + const installedSkills = [{ name: "agent-builder" }, { name: "local-only" }]; expect( hasInstalledRemoteSkill(installedSkills, { name: "Agent Builder", - remoteSource: "https://opencsg-stg.example.test/", remotePath: "AIWizards/agent-builder", }), ).toBe(true); expect( hasInstalledRemoteSkill(installedSkills, { - name: "local-only", - remotePath: "another-owner/local-only", - }), - ).toBe(false); - expect( - hasInstalledRemoteSkill(installedSkills, { - name: "agent-builder", - remoteSource: "https://another-hub.example.test", - remotePath: "AIWizards/agent-builder", - }), - ).toBe(false); - expect( - hasInstalledRemoteSkill(installedSkills, { - name: "agent-builder", - remoteSource: "https://opencsg-stg.example.test", - remotePath: "AIWizards//agent-builder", + name: "missing", + remotePath: "AIWizards/missing", }), ).toBe(false); });