Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 3 additions & 14 deletions web/app/src/api/skills.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -134,7 +134,8 @@ function normalizeAgenticHubSkill(record: unknown, source: string): SkillSummary
return null;
}
const values = record as Record<string, unknown>;
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;
Expand All @@ -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() : "";
}
Expand Down
20 changes: 17 additions & 3 deletions web/app/src/hooks/workspace/useWorkspaceHubSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions web/app/src/models/skillhub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>;
onLoadMoreRemoteSkills?: () => Promise<unknown>;
Expand Down Expand Up @@ -51,6 +53,7 @@ export function SkillUploadDialog({
onSubmit,
busy,
error,
installedSkills,
locale,
remoteInstallBusy,
remoteInstallError,
Expand Down Expand Up @@ -131,15 +134,15 @@ export function SkillUploadDialog({

const handleRemoteInstall = useCallback(
async (skill: SkillSummary) => {
if (!onInstallRemoteSkill) {
if (!onInstallRemoteSkill || hasInstalledRemoteSkill(installedSkills, skill)) {
return;
}
const result = await onInstallRemoteSkill(skill);
if (result) {
onOpenChange(false);
}
},
[onInstallRemoteSkill, onOpenChange],
[installedSkills, onInstallRemoteSkill, onOpenChange],
);

const handleRemoteListScroll = useCallback(
Expand Down Expand Up @@ -255,32 +258,38 @@ export function SkillUploadDialog({
) : remoteSkills.length ? (
<>
<div className="hub-skill-remote-list" onScroll={handleRemoteListScroll}>
{remoteSkills.map((item) => (
<div className="hub-skill-remote-row" key={item.remotePath || item.name}>
<span className="hub-skill-remote-icon" aria-hidden="true">
<FileCode2 size={16} strokeWidth={2} />
</span>
<span className="hub-skill-remote-main">
<span className="hub-skill-remote-title truncate">{item.name}</span>
<span className="hub-skill-remote-meta truncate">{item.description || item.remotePath}</span>
</span>
<span className="mini-badge template-source-badge">
<span className="template-source-badge-dot" aria-hidden="true"></span>
{localizeTemplateSourceTag("official", locale)}
</span>
<Button
size="sm"
variant="primary"
loading={remoteInstallBusy === (item.remotePath || item.name)}
disabled={!onInstallRemoteSkill || Boolean(remoteInstallBusy)}
onClick={() => void handleRemoteInstall(item)}
>
{remoteInstallBusy === (item.remotePath || item.name)
? t("resourcesSkillRemoteInstalling")
: t("resourcesSkillRemoteInstallAction")}
</Button>
</div>
))}
{remoteSkills.map((item) => {
const remoteKey = item.remotePath || item.name;
const installed = hasInstalledRemoteSkill(installedSkills, item);
const installing = remoteInstallBusy === remoteKey;
return (
<div className="hub-skill-remote-row" key={remoteKey}>
<span className="hub-skill-remote-icon" aria-hidden="true">
<FileCode2 size={16} strokeWidth={2} />
</span>
<span className="hub-skill-remote-main">
<span className="hub-skill-remote-title truncate">{item.name}</span>
<span className="hub-skill-remote-meta truncate">
{item.description || item.remotePath}
</span>
</span>
<span className="mini-badge template-source-badge">
<span className="template-source-badge-dot" aria-hidden="true"></span>
{localizeTemplateSourceTag("official", locale)}
</span>
<Button
className={`hub-skill-remote-install-button ${installed ? "installed" : ""}`}
size="sm"
variant={installed ? "secondaryGray" : "primary"}
loading={!installed && installing}
disabled={installed || !onInstallRemoteSkill || Boolean(remoteInstallBusy)}
onClick={() => void handleRemoteInstall(item)}
>
{installing ? t("resourcesSkillRemoteInstalling") : t("resourcesSkillRemoteInstallAction")}
</Button>
</div>
);
})}
{remoteSkillsLoadingMore ? (
<div className="hub-skill-remote-list-state">{t("resourcesSkillRemoteSkillsLoading")}</div>
) : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -692,9 +692,7 @@ export function WorkspaceTabPanels({
<button
key={item.id}
className={`workspace-row hub-template-row ${
resourcesPaneActive &&
selectedHubTemplateId === item.id &&
selectedHubResourceType === "template"
resourcesPaneActive && selectedHubTemplateId === item.id && selectedHubResourceType === "template"
? "active"
: ""
}`}
Expand Down Expand Up @@ -735,9 +733,7 @@ export function WorkspaceTabPanels({
<button
key={item.name}
className={`workspace-row hub-template-row hub-skill-row ${
resourcesPaneActive &&
selectedHubSkillName === item.name &&
selectedHubResourceType === "skill"
resourcesPaneActive && selectedHubSkillName === item.name && selectedHubResourceType === "skill"
? "active"
: ""
}`}
Expand Down Expand Up @@ -772,6 +768,7 @@ export function WorkspaceTabPanels({
onSubmit={(file) => hub?.uploadSkill?.(file)}
busy={resourcesUploadBusy}
error={resourcesUploadError}
installedSkills={resourcesSkills}
locale={locale}
onInstallRemoteSkill={hub?.installRemoteSkill}
onLoadMoreRemoteSkills={hub?.loadMoreRemoteSkills}
Expand Down
5 changes: 4 additions & 1 deletion web/app/src/shared/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export const messages = {
resourcesSkillRemoteSkillsEmpty: "暂无远端 skills。",
resourcesSkillRemoteSkillsLoadFailed: "远端 skills 加载失败,请稍后重试。",
resourcesSkillRemoteInstallAction: "安装",
resourcesSkillRemoteInstalled: "已安装",
resourcesSkillRemoteInstalling: "安装中...",
resourcesSkillRemoteInstallFailed: "远端 skill 安装失败,请稍后重试。",
resourcesSkillRemoteRefresh: "刷新",
Expand Down Expand Up @@ -916,12 +917,14 @@ export const messages = {
resourcesSkillRemoteSkillsEmpty: "No remote skills yet.",
resourcesSkillRemoteSkillsLoadFailed: "Failed to load remote skills. Please try again later.",
resourcesSkillRemoteInstallAction: "Install",
resourcesSkillRemoteInstalled: "Installed",
resourcesSkillRemoteInstalling: "Installing...",
resourcesSkillRemoteInstallFailed: "Failed to install the remote skill. Please try again later.",
resourcesSkillRemoteRefresh: "Refresh",
resourcesSkillRemoteSearchPlaceholder: "Search remote skills",
resourcesSkillUploadDropTitle: "Click to choose a zip, or drag it here",
resourcesSkillUploadDropHint: "Only .zip files are supported, and the archive must contain exactly one valid skill.",
resourcesSkillUploadDropHint:
"Only .zip files are supported, and the archive must contain exactly one valid skill.",
resourcesSkillUploadChoose: "Choose zip",
resourcesSkillUploadSubmitting: "Uploading...",
resourcesSkillUploadSubmit: "Upload",
Expand Down
65 changes: 65 additions & 0 deletions web/app/tests/components/WorkspaceTabPanels.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const labels: Record<string, string> = {
resourcesSkillsLabel: "Skills",
resourcesSkillRemoteInstallFailed: "Install failed",
resourcesSkillRemoteInstallAction: "Install",
resourcesSkillRemoteInstalled: "Installed",
resourcesSkillRemoteInstallTab: "Remote install",
resourcesSkillRemoteInstalling: "Installing",
resourcesSkillRemoteSearchPlaceholder: "Search remote skills",
Expand Down Expand Up @@ -401,4 +402,68 @@ describe("WorkspaceTabPanels", () => {
fireEvent.click(screen.getByRole("button", { name: "Install" }));
expect(installRemoteSkill).toHaveBeenCalledWith(expect.objectContaining({ remotePath: "AIWizards/agent-builder" }));
});

it("disables remote skill install actions for locally installed skills", () => {
const installRemoteSkill = vi.fn(async () => ({ name: "agent-builder" }));
const remoteHub = {
...hub,
installRemoteSkill,
remoteInstallBusy: "",
remoteInstallError: "",
remoteSkillsHasMore: false,
remoteSkillsLoadingMore: false,
remoteSkillsSearch: "",
skills: [{ name: "agent-builder", description: "Already installed" }],
} as unknown as WorkspaceSidebarProps["hub"];

render(
<WorkspaceTabPanels
activePane={{ type: WorkspacePaneTypes.hub, id: "hub" }}
activeThreadRootID=""
agentItems={[managerAgent]}
agentsError=""
channels={[]}
collapsedWorkspaceGroups={{}}
currentUserID="u-admin"
directMessages={[]}
hub={remoteHub}
locale="en"
notificationAgentItems={[]}
onCreateAgent={() => {}}
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 installedButton = screen.getByRole("button", { name: "Install" });
expect(installedButton).toBeDisabled();
fireEvent.click(installedButton);
expect(installRemoteSkill).not.toHaveBeenCalled();
});
});
Loading
Loading