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:
FraterCCCLXIII
2026-05-22 15:38:33 +07:00
committed by GitHub
co-authored by Cursor hieptl
parent b027b86213
commit 04ecb58e70
12 changed files with 446 additions and 84 deletions
@@ -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);
+66
View File
@@ -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>
);
+51
View File
@@ -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": "公開で共有",
+123
View File
@@ -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;
}