mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:19:05 +08:00
feat(skills): redesign available skills modal layout and grouping (#720)
* feat(skills): redesign available skills modal layout and grouping Polish the conversation skills modal with scoped sections, clearer header controls, monochrome pills, and consistent spacing while runtime or data loads. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(skills): align modal section labels with API terminology Rename Personal/Built-in section headers to User skills and Public skills so the Available Skills modal matches load_user/load_public naming. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: translation.json --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Cursor
hieptl
parent
b027b86213
commit
04ecb58e70
@@ -50,12 +50,30 @@ describe("SkillsModal", () => {
|
||||
});
|
||||
|
||||
describe("Refresh Button Rendering", () => {
|
||||
it("should render the refresh button with correct text and test ID", async () => {
|
||||
it("should render the refresh button as an icon-only control with accessible label", async () => {
|
||||
renderWithProviders(<SkillsModal {...defaultProps} />);
|
||||
|
||||
const refreshButton = await screen.findByTestId("refresh-skills");
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
expect(refreshButton).toHaveTextContent("BUTTON$REFRESH");
|
||||
expect(refreshButton).toHaveAttribute(
|
||||
"aria-label",
|
||||
"BUTTON$REFRESH",
|
||||
);
|
||||
expect(refreshButton).not.toHaveTextContent("BUTTON$REFRESH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Close Button", () => {
|
||||
it("should render the close button and call onClose when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<SkillsModal {...defaultProps} />);
|
||||
|
||||
const closeButton = await screen.findByTestId("close-skills-modal");
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,6 +94,24 @@ describe("SkillsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Runtime waiting state", () => {
|
||||
it("shows the warning, refresh button, and spinner while the runtime is starting", async () => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.LOADING,
|
||||
});
|
||||
|
||||
renderWithProviders(<SkillsModal {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByTestId("refresh-skills")).toBeInTheDocument();
|
||||
expect(screen.getByText("SKILLS_MODAL$WARNING")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("skills-runtime-waiting")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("DIFF_VIEWER$WAITING_FOR_RUNTIME"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Skills Display", () => {
|
||||
it("should display skills correctly", async () => {
|
||||
vi.spyOn(SkillsService, "getSkills").mockResolvedValue(mockSkills);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SkillInfo } from "#/types/settings";
|
||||
import { getSkillScope, groupSkillsByScope } from "#/utils/skill-scope";
|
||||
|
||||
function buildSkill(overrides: Partial<SkillInfo> = {}): SkillInfo {
|
||||
return {
|
||||
name: "test-skill",
|
||||
type: "knowledge",
|
||||
source: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getSkillScope", () => {
|
||||
it("classifies public catalog skills", () => {
|
||||
expect(
|
||||
getSkillScope(
|
||||
buildSkill({
|
||||
source: "/Users/test/.openhands/cache/skills/public-skills/skills/deno/SKILL.md",
|
||||
}),
|
||||
),
|
||||
).toBe("public");
|
||||
expect(getSkillScope(buildSkill({ source: "public" }))).toBe("public");
|
||||
});
|
||||
|
||||
it("classifies personal user skills from home directories", () => {
|
||||
expect(
|
||||
getSkillScope(
|
||||
buildSkill({
|
||||
source: "/Users/test/.agents/skills/my-skill/SKILL.md",
|
||||
}),
|
||||
),
|
||||
).toBe("personal");
|
||||
expect(getSkillScope(buildSkill({ source: "user" }))).toBe("personal");
|
||||
});
|
||||
|
||||
it("classifies project skills from the workspace", () => {
|
||||
const projectDir = "/workspace/project/agent-canvas";
|
||||
expect(
|
||||
getSkillScope(
|
||||
buildSkill({
|
||||
source: `${projectDir}/.agents/skills/default-tools/SKILL.md`,
|
||||
}),
|
||||
projectDir,
|
||||
),
|
||||
).toBe("project");
|
||||
expect(getSkillScope(buildSkill({ source: "project" }))).toBe("project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupSkillsByScope", () => {
|
||||
it("groups and sorts skills by scope", () => {
|
||||
const grouped = groupSkillsByScope([
|
||||
buildSkill({ name: "beta", source: "public" }),
|
||||
buildSkill({ name: "alpha", source: "user" }),
|
||||
buildSkill({
|
||||
name: "gamma",
|
||||
source: "/workspace/project/.agents/skills/gamma/SKILL.md",
|
||||
}),
|
||||
], "/workspace/project");
|
||||
|
||||
expect(grouped.public.map((skill) => skill.name)).toEqual(["beta"]);
|
||||
expect(grouped.personal.map((skill) => skill.name)).toEqual(["alpha"]);
|
||||
expect(grouped.project.map((skill) => skill.name)).toEqual(["gamma"]);
|
||||
});
|
||||
});
|
||||
@@ -16,17 +16,14 @@ export function SkillContent({ content }: SkillContentProps) {
|
||||
{t(I18nKey.COMMON$CONTENT)}
|
||||
</Typography.Text>
|
||||
<Pre
|
||||
size="default"
|
||||
size="small"
|
||||
font="mono"
|
||||
lineHeight="relaxed"
|
||||
background="dark"
|
||||
textColor="light"
|
||||
padding="medium"
|
||||
borderRadius="medium"
|
||||
shadow="inner"
|
||||
maxHeight="small"
|
||||
overflow="auto"
|
||||
className="mt-2"
|
||||
className="mt-2 border border-[var(--oh-border)] bg-base text-[var(--oh-text-tertiary)]"
|
||||
>
|
||||
{content || t(I18nKey.SKILLS_MODAL$NO_CONTENT)}
|
||||
</Pre>
|
||||
|
||||
@@ -10,32 +10,32 @@ interface SkillItemProps {
|
||||
onToggle: (agentName: string) => void;
|
||||
}
|
||||
|
||||
export function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) {
|
||||
let skillTypeLabel: string;
|
||||
if (skill.type === "repo") {
|
||||
skillTypeLabel = "Repository";
|
||||
} else if (skill.type === "knowledge") {
|
||||
skillTypeLabel = "Knowledge";
|
||||
} else {
|
||||
skillTypeLabel = "AgentSkills";
|
||||
}
|
||||
const SKILL_TYPE_LABEL: Record<SkillInfo["type"], string> = {
|
||||
knowledge: "Knowledge",
|
||||
repo: "Repository",
|
||||
agentskills: "AgentSkills",
|
||||
};
|
||||
|
||||
const SKILL_PILL_CLASS =
|
||||
"inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-4 border border-[var(--oh-border)] bg-[var(--oh-surface)] text-tertiary-light";
|
||||
|
||||
export function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) {
|
||||
return (
|
||||
<div className="rounded-md overflow-hidden">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(skill.name)}
|
||||
className="w-full py-3 px-2 text-left flex items-center justify-between hover:bg-tertiary transition-colors"
|
||||
className="w-full py-3 px-3 text-left flex items-center justify-between hover:bg-tertiary transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Typography.Text className="font-bold text-content-2">
|
||||
{skill.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Typography.Text className="px-2 py-1 text-xs rounded-full bg-[var(--oh-surface)] mr-2">
|
||||
{skillTypeLabel}
|
||||
</Typography.Text>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={SKILL_PILL_CLASS}>
|
||||
{SKILL_TYPE_LABEL[skill.type]}
|
||||
</span>
|
||||
<Typography.Text className="text-[var(--oh-text-tertiary)]">
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={18} />
|
||||
@@ -47,7 +47,7 @@ export function SkillItem({ skill, isExpanded, onToggle }: SkillItemProps) {
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-2 pb-3 pt-1">
|
||||
<div className="px-3 pb-3 pt-1 border-t border-[var(--oh-border)]">
|
||||
<SkillTriggers triggers={skill.triggers ?? []} />
|
||||
<SkillContent content={skill.content ?? ""} />
|
||||
</div>
|
||||
|
||||
@@ -18,14 +18,14 @@ export function SkillTriggers({ triggers }: SkillTriggersProps) {
|
||||
<Typography.Text className="text-sm font-semibold text-[var(--oh-text-tertiary)] mb-2">
|
||||
{t(I18nKey.COMMON$TRIGGERS)}
|
||||
</Typography.Text>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{triggers.map((trigger) => (
|
||||
<Typography.Text
|
||||
<span
|
||||
key={trigger}
|
||||
className="px-2 py-1 text-xs rounded-full bg-blue-900"
|
||||
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-4 border border-[var(--oh-border)] bg-[var(--oh-surface)] text-tertiary-light"
|
||||
>
|
||||
{trigger}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,45 +1,64 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { RefreshCw, X } from "lucide-react";
|
||||
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface SkillsModalHeaderProps {
|
||||
isAgentReady: boolean;
|
||||
isLoading: boolean;
|
||||
isRefetching: boolean;
|
||||
onRefresh: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ICON_BUTTON_CLASS =
|
||||
"rounded-md p-1 text-white hover:bg-tertiary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed";
|
||||
|
||||
export function SkillsModalHeader({
|
||||
isAgentReady,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
onRefresh,
|
||||
onClose,
|
||||
}: SkillsModalHeaderProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const refreshLabel = t(I18nKey.BUTTON$REFRESH);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
<BaseModalTitle title={t(I18nKey.SKILLS_MODAL$TITLE)} />
|
||||
{isAgentReady && (
|
||||
<BrandButton
|
||||
testId="refresh-skills"
|
||||
<Typography.Text className="text-sm text-[var(--oh-muted)]">
|
||||
{t(I18nKey.SKILLS_MODAL$WARNING)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<StyledTooltip content={refreshLabel} placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="flex items-center gap-2"
|
||||
data-testid="refresh-skills"
|
||||
onClick={onRefresh}
|
||||
isDisabled={isLoading || isRefetching}
|
||||
disabled={isLoading || isRefetching}
|
||||
aria-label={refreshLabel}
|
||||
className={ICON_BUTTON_CLASS}
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
size={18}
|
||||
className={cn(isRefetching && "animate-spin")}
|
||||
aria-hidden
|
||||
/>
|
||||
{t(I18nKey.BUTTON$REFRESH)}
|
||||
</BrandButton>
|
||||
)}
|
||||
</button>
|
||||
</StyledTooltip>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={ICON_BUTTON_CLASS}
|
||||
aria-label={t(I18nKey.BUTTON$CLOSE)}
|
||||
data-testid="close-skills-modal"
|
||||
>
|
||||
<X size={20} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface SkillsModalSectionProps {
|
||||
title: string;
|
||||
count: number;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SkillsModalSection({
|
||||
title,
|
||||
count,
|
||||
children,
|
||||
}: SkillsModalSectionProps) {
|
||||
return (
|
||||
<section className="w-full">
|
||||
<div className="sticky top-0 z-10 border-b border-[var(--oh-border)] bg-surface-raised px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Typography.Text className="text-xs font-semibold tracking-[0.01em] text-tertiary-light">
|
||||
{title}
|
||||
</Typography.Text>
|
||||
<span className="inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-4 border border-[var(--oh-border)] bg-[var(--oh-surface)] text-tertiary-light">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--oh-border)]">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { getAgentServerWorkingDir } from "#/api/agent-server-config";
|
||||
import { useSkills } from "#/hooks/query/use-skills";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import {
|
||||
groupSkillsByScope,
|
||||
SKILL_SCOPE_ORDER,
|
||||
type SkillScope,
|
||||
} from "#/utils/skill-scope";
|
||||
import { SkillsModalHeader } from "./skills-modal-header";
|
||||
import { SkillsModalSection } from "./skills-modal-section";
|
||||
import { SkillsRuntimeWaitingState } from "./skills-runtime-waiting-state";
|
||||
import { SkillsLoadingState } from "./skills-loading-state";
|
||||
import { SkillsEmptyState } from "./skills-empty-state";
|
||||
import { SkillItem } from "./skill-item";
|
||||
@@ -16,9 +23,16 @@ interface SkillsModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SECTION_TITLE_KEY: Record<SkillScope, I18nKey> = {
|
||||
project: I18nKey.SKILLS_MODAL$SECTION_PROJECT,
|
||||
personal: I18nKey.SKILLS_MODAL$SECTION_USER,
|
||||
public: I18nKey.SKILLS_MODAL$SECTION_PUBLIC,
|
||||
};
|
||||
|
||||
export function SkillsModal({ onClose }: SkillsModalProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const { curAgentState } = useAgentState();
|
||||
const projectDir = getAgentServerWorkingDir();
|
||||
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
@@ -30,6 +44,11 @@ export function SkillsModal({ onClose }: SkillsModalProps) {
|
||||
isRefetching,
|
||||
} = useSkills();
|
||||
|
||||
const groupedSkills = useMemo(
|
||||
() => (skills ? groupSkillsByScope(skills, projectDir) : null),
|
||||
[skills, projectDir],
|
||||
);
|
||||
|
||||
const toggleAgent = (agentName: string) => {
|
||||
setExpandedAgents((prev) => ({
|
||||
...prev,
|
||||
@@ -45,54 +64,55 @@ export function SkillsModal({ onClose }: SkillsModalProps) {
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<ModalBody
|
||||
width="medium"
|
||||
className="max-h-[80vh] flex flex-col items-start"
|
||||
className="max-h-[80vh] flex flex-col items-start border border-[var(--oh-border)]"
|
||||
testID="skills-modal"
|
||||
>
|
||||
<SkillsModalHeader
|
||||
isAgentReady={isAgentReady}
|
||||
isLoading={isLoading}
|
||||
isRefetching={isRefetching}
|
||||
onRefresh={refetch}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
{isAgentReady && (
|
||||
<Typography.Text className="text-sm text-[var(--oh-muted)]">
|
||||
{t(I18nKey.SKILLS_MODAL$WARNING)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<div className="w-full h-[60vh] overflow-auto rounded-md border border-[var(--oh-border)] bg-surface-raised custom-scrollbar-always">
|
||||
{!isAgentReady ? (
|
||||
<SkillsRuntimeWaitingState />
|
||||
) : isLoading ? (
|
||||
<SkillsLoadingState />
|
||||
) : isError || !skills || skills.length === 0 ? (
|
||||
<SkillsEmptyState isError={isError} />
|
||||
) : (
|
||||
groupedSkills && (
|
||||
<div className="divide-y divide-[var(--oh-border)]">
|
||||
{SKILL_SCOPE_ORDER.map((scope) => {
|
||||
const scopedSkills = groupedSkills[scope];
|
||||
if (scopedSkills.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
<div className="w-full h-[60vh] overflow-auto rounded-md custom-scrollbar-always">
|
||||
{!isAgentReady && (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
<Typography.Text>
|
||||
{t(I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<SkillsModalSection
|
||||
key={scope}
|
||||
title={t(SECTION_TITLE_KEY[scope])}
|
||||
count={scopedSkills.length}
|
||||
>
|
||||
{scopedSkills.map((skill) => {
|
||||
const isExpanded = expandedAgents[skill.name] || false;
|
||||
|
||||
{isLoading && <SkillsLoadingState />}
|
||||
|
||||
{!isLoading &&
|
||||
isAgentReady &&
|
||||
(isError || !skills || skills.length === 0) && (
|
||||
<SkillsEmptyState isError={isError} />
|
||||
)}
|
||||
|
||||
{!isLoading && isAgentReady && skills && skills.length > 0 && (
|
||||
<div className="p-2 space-y-3">
|
||||
{skills.map((skill) => {
|
||||
const isExpanded = expandedAgents[skill.name] || false;
|
||||
|
||||
return (
|
||||
<SkillItem
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={toggleAgent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
return (
|
||||
<SkillItem
|
||||
key={`${scope}-${skill.name}`}
|
||||
skill={skill}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={toggleAgent}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SkillsModalSection>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
export function SkillsRuntimeWaitingState() {
|
||||
const { t } = useTranslation("openhands");
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="skills-runtime-waiting"
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-3 py-8 text-center"
|
||||
>
|
||||
<LoadingSpinner size="small" />
|
||||
<Typography.Text className="text-sm text-[var(--oh-muted)]">
|
||||
{t(I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ interface BaseModalTitleProps {
|
||||
|
||||
export function BaseModalTitle({ title }: BaseModalTitleProps) {
|
||||
return (
|
||||
<span className="text-xl leading-6 -tracking-[0.01em] font-semibold">
|
||||
<span className="text-xl leading-6 -tracking-[0.01em] font-semibold text-content-2">
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -22847,6 +22847,57 @@
|
||||
"uk": "Доступні навички",
|
||||
"ca": "Habilitats disponibles"
|
||||
},
|
||||
"SKILLS_MODAL$SECTION_PROJECT": {
|
||||
"en": "Project skills",
|
||||
"ja": "プロジェクトスキル",
|
||||
"zh-CN": "项目技能",
|
||||
"zh-TW": "專案技能",
|
||||
"ko-KR": "프로젝트 스킬",
|
||||
"no": "Prosjektferdigheter",
|
||||
"ar": "مهارات المشروع",
|
||||
"de": "Projekt-Fähigkeiten",
|
||||
"fr": "Compétences du projet",
|
||||
"it": "Abilità del progetto",
|
||||
"pt": "Habilidades do projeto",
|
||||
"es": "Habilidades del proyecto",
|
||||
"tr": "Proje yetenekleri",
|
||||
"uk": "Навички проєкту",
|
||||
"ca": "Habilitats del projecte"
|
||||
},
|
||||
"SKILLS_MODAL$SECTION_USER": {
|
||||
"en": "User skills",
|
||||
"ja": "ユーザースキル",
|
||||
"zh-CN": "用户技能",
|
||||
"zh-TW": "使用者技能",
|
||||
"ko-KR": "사용자 스킬",
|
||||
"no": "Brukerferdigheter",
|
||||
"ar": "مهارات المستخدم",
|
||||
"de": "Benutzer-Fähigkeiten",
|
||||
"fr": "Compétences utilisateur",
|
||||
"it": "Abilità utente",
|
||||
"pt": "Habilidades do usuário",
|
||||
"es": "Habilidades del usuario",
|
||||
"tr": "Kullanıcı yetenekleri",
|
||||
"uk": "Навички користувача",
|
||||
"ca": "Habilitats de l'usuari"
|
||||
},
|
||||
"SKILLS_MODAL$SECTION_PUBLIC": {
|
||||
"en": "Public skills",
|
||||
"ja": "パブリックスキル",
|
||||
"zh-CN": "公共技能",
|
||||
"zh-TW": "公開技能",
|
||||
"ko-KR": "공개 스킬",
|
||||
"no": "Offentlige ferdigheter",
|
||||
"ar": "المهارات العامة",
|
||||
"de": "Öffentliche Fähigkeiten",
|
||||
"fr": "Compétences publiques",
|
||||
"it": "Abilità pubbliche",
|
||||
"pt": "Habilidades públicas",
|
||||
"es": "Habilidades públicas",
|
||||
"tr": "Genel yetenekler",
|
||||
"uk": "Загальнодоступні навички",
|
||||
"ca": "Habilitats públiques"
|
||||
},
|
||||
"CONVERSATION$SHARE_PUBLICLY": {
|
||||
"en": "Public Share",
|
||||
"ja": "公開で共有",
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { SkillInfo } from "#/types/settings";
|
||||
|
||||
export type SkillScope = "project" | "personal" | "public";
|
||||
|
||||
export const SKILL_SCOPE_ORDER: SkillScope[] = [
|
||||
"project",
|
||||
"personal",
|
||||
"public",
|
||||
];
|
||||
|
||||
const USER_SKILL_DIR_MARKERS = [
|
||||
"/.agents/skills/",
|
||||
"/.openhands/skills/",
|
||||
"/.openhands/microagents/",
|
||||
] as const;
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
function isPublicSource(source: string): boolean {
|
||||
const norm = normalizePath(source).toLowerCase();
|
||||
if (norm === "public") {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
norm.includes("public-skills") || norm.includes("/.openhands/cache/skills/")
|
||||
);
|
||||
}
|
||||
|
||||
function isUserHomeSkillPath(source: string): boolean {
|
||||
const norm = normalizePath(source);
|
||||
if (/^\/Users\/[^/]+\/\.(agents|openhands)\//.test(norm)) {
|
||||
return true;
|
||||
}
|
||||
if (/^\/home\/[^/]+\/\.(agents|openhands)\//.test(norm)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return USER_SKILL_DIR_MARKERS.some((marker) => {
|
||||
const markerIndex = norm.indexOf(marker);
|
||||
if (markerIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
const prefix = norm.slice(0, markerIndex);
|
||||
return /^\/Users\/[^/]+$/.test(prefix) || /^\/home\/[^/]+$/.test(prefix);
|
||||
});
|
||||
}
|
||||
|
||||
function isProjectSkillPath(
|
||||
source: string,
|
||||
projectDir?: string | null,
|
||||
): boolean {
|
||||
const norm = normalizePath(source);
|
||||
const hasProjectMarker = USER_SKILL_DIR_MARKERS.some((marker) =>
|
||||
norm.includes(marker),
|
||||
);
|
||||
if (!hasProjectMarker) {
|
||||
return false;
|
||||
}
|
||||
if (isUserHomeSkillPath(source)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (projectDir) {
|
||||
const projectNorm = normalizePath(projectDir).replace(/\/$/, "");
|
||||
if (norm.startsWith(projectNorm)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getSkillScope(
|
||||
skill: SkillInfo,
|
||||
projectDir?: string | null,
|
||||
): SkillScope {
|
||||
const source = skill.source?.trim();
|
||||
if (!source) {
|
||||
return skill.type === "repo" ? "project" : "public";
|
||||
}
|
||||
|
||||
const lower = source.toLowerCase();
|
||||
if (isPublicSource(source)) {
|
||||
return "public";
|
||||
}
|
||||
if (lower === "user" || lower === "global") {
|
||||
return "personal";
|
||||
}
|
||||
if (lower === "project" || lower === "repo" || lower === "sandbox") {
|
||||
return "project";
|
||||
}
|
||||
if (isUserHomeSkillPath(source)) {
|
||||
return "personal";
|
||||
}
|
||||
if (isProjectSkillPath(source, projectDir)) {
|
||||
return "project";
|
||||
}
|
||||
|
||||
return "public";
|
||||
}
|
||||
|
||||
export function groupSkillsByScope(
|
||||
skills: SkillInfo[],
|
||||
projectDir?: string | null,
|
||||
): Record<SkillScope, SkillInfo[]> {
|
||||
const groups: Record<SkillScope, SkillInfo[]> = {
|
||||
project: [],
|
||||
personal: [],
|
||||
public: [],
|
||||
};
|
||||
|
||||
for (const skill of skills) {
|
||||
groups[getSkillScope(skill, projectDir)].push(skill);
|
||||
}
|
||||
|
||||
for (const scope of SKILL_SCOPE_ORDER) {
|
||||
groups[scope].sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
Reference in New Issue
Block a user