style(webui): refine skills mobile experience

This commit is contained in:
Xubin Ren 2026-07-27 21:31:34 +08:00
parent 655cd80651
commit 9b5a5c5302
4 changed files with 105 additions and 25 deletions

View File

@ -146,7 +146,12 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
className="h-9 rounded-[11px] bg-background pl-9 text-[13px]" className="h-9 rounded-[11px] bg-background pl-9 text-[13px]"
/> />
</div> </div>
<div className="flex items-center gap-1 rounded-[10px] bg-muted/65 p-1"> <div
className={cn(
"flex max-w-full items-center gap-1 overflow-x-auto rounded-[10px] bg-muted/65 p-1",
"scrollbar-thin scrollbar-track-transparent sm:w-auto",
)}
>
{([ {([
["all", t("settings.skills.filterAll", { defaultValue: "All" }), skills.length], ["all", t("settings.skills.filterAll", { defaultValue: "All" }), skills.length],
[ [
@ -165,7 +170,7 @@ export function SkillsCatalogSettings({ skills }: { skills: SkillSummary[] }) {
type="button" type="button"
onClick={() => setInstalledFilter(filter)} onClick={() => setInstalledFilter(filter)}
className={cn( className={cn(
"rounded-[8px] px-2.5 py-1 text-[11px] font-medium transition-colors", "shrink-0 whitespace-nowrap rounded-[8px] px-2.5 py-1 text-[11px] font-medium transition-colors",
installedFilter === filter installedFilter === filter
? "bg-background text-foreground shadow-sm" ? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",
@ -314,6 +319,7 @@ function SkillDetailSheet({
const [actionError, setActionError] = useState(""); const [actionError, setActionError] = useState("");
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
useEffect(() => { useEffect(() => {
if (!open || !skill) return; if (!open || !skill) return;
@ -323,6 +329,7 @@ function SkillDetailSheet({
setLoadFailed(false); setLoadFailed(false);
setActionError(""); setActionError("");
setDeleteOpen(false); setDeleteOpen(false);
setDescriptionExpanded(false);
fetchSkillDetail(token, skill.name) fetchSkillDetail(token, skill.name)
.then((payload) => { .then((payload) => {
if (!cancelled) setDetail(payload); if (!cancelled) setDetail(payload);
@ -349,6 +356,7 @@ function SkillDetailSheet({
: activeSkill.available : activeSkill.available
? t("settings.skills.statusEnabled", { defaultValue: "Enabled" }) ? t("settings.skills.statusEnabled", { defaultValue: "Enabled" })
: t("settings.skills.statusNeedsSetup", { defaultValue: "Needs setup" }); : t("settings.skills.statusNeedsSetup", { defaultValue: "Needs setup" });
const descriptionExpandable = activeSkill.description.length > 240;
const toggleEnabled = async () => { const toggleEnabled = async () => {
setActionBusy(true); setActionBusy(true);
@ -399,12 +407,25 @@ function SkillDetailSheet({
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent <SheetContent
side="right" side="right"
className="w-[min(34rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none" closeButtonClassName={cn(
"right-2 top-2 inline-flex h-10 w-10 items-center justify-center rounded-full opacity-100",
"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"sm:right-3 sm:top-3",
)}
className={cn(
"w-full max-w-none gap-0 overflow-hidden border-l-0 p-0",
"sm:w-[min(34rem,calc(100vw-1rem))] sm:max-w-none sm:border-l",
)}
> >
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5"> <div
className={cn(
"min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5 sm:py-5",
"pb-[max(1rem,env(safe-area-inset-bottom))]",
)}
>
<div className="flex items-start gap-3 pr-8"> <div className="flex items-start gap-3 pr-8">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<SheetTitle className="truncate text-[20px] font-semibold"> <SheetTitle className="truncate text-[19px] font-semibold sm:text-[20px]">
{activeSkill.name} {activeSkill.name}
</SheetTitle> </SheetTitle>
<SheetDescription className="sr-only"> <SheetDescription className="sr-only">
@ -427,9 +448,26 @@ function SkillDetailSheet({
{statusLabel} {statusLabel}
</Pill> </Pill>
</div> </div>
<p className="mt-3 text-[13px] leading-5 text-muted-foreground"> <p
className={cn(
"mt-3 text-[13px] leading-5 text-muted-foreground",
descriptionExpandable && !descriptionExpanded && "line-clamp-5",
)}
>
{activeSkill.description} {activeSkill.description}
</p> </p>
{descriptionExpandable ? (
<button
type="button"
aria-expanded={descriptionExpanded}
onClick={() => setDescriptionExpanded((value) => !value)}
className="mt-1.5 min-h-8 rounded-full text-[12px] font-medium text-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{descriptionExpanded
? t("settings.skills.showLess", { defaultValue: "Show less" })
: t("settings.skills.showMore", { defaultValue: "Show more" })}
</button>
) : null}
</div> </div>
</div> </div>
@ -444,7 +482,7 @@ function SkillDetailSheet({
</div> </div>
) : ( ) : (
<div className="mt-6 space-y-5"> <div className="mt-6 space-y-5">
<div className="flex items-center justify-between gap-4 border-y border-border/45 px-1 py-3.5"> <div className="flex min-h-16 items-start justify-between gap-3 border-y border-border/45 px-1 py-3.5">
<div> <div>
<p className="text-[13px] font-medium text-foreground"> <p className="text-[13px] font-medium text-foreground">
{t("settings.skills.enabledControl", { defaultValue: "Use this skill" })} {t("settings.skills.enabledControl", { defaultValue: "Use this skill" })}
@ -467,15 +505,20 @@ function SkillDetailSheet({
disabled={actionBusy} disabled={actionBusy}
onClick={() => void toggleEnabled()} onClick={() => void toggleEnabled()}
className={cn( className={cn(
"relative h-6 w-11 shrink-0 rounded-full transition-colors", "relative -mr-1 h-10 w-14 shrink-0 rounded-full",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-wait disabled:opacity-60", "disabled:cursor-wait disabled:opacity-60",
enabled ? "bg-foreground" : "bg-muted-foreground/30",
)} )}
> >
<span <span
className={cn( className={cn(
"absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-background shadow-sm transition-transform", "absolute left-1.5 top-2 h-6 w-11 rounded-full transition-colors",
enabled ? "bg-foreground" : "bg-muted-foreground/30",
)}
/>
<span
className={cn(
"absolute left-2 top-2.5 h-5 w-5 rounded-full bg-background shadow-sm transition-transform",
enabled ? "translate-x-5" : "translate-x-0", enabled ? "translate-x-5" : "translate-x-0",
)} )}
/> />
@ -498,7 +541,7 @@ function SkillDetailSheet({
{detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null} {detail ? <RawInstructionsBlock markdown={detail.raw_markdown} /> : null}
{deletable ? ( {deletable ? (
<div className="flex items-center justify-between gap-4 border-t border-border/45 pt-5"> <div className="flex flex-col items-stretch gap-3 border-t border-border/45 pt-5 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div> <div>
<p className="text-[13px] font-medium text-foreground"> <p className="text-[13px] font-medium text-foreground">
{t("settings.skills.deleteTitle", { defaultValue: "Delete skill" })} {t("settings.skills.deleteTitle", { defaultValue: "Delete skill" })}
@ -511,11 +554,14 @@ function SkillDetailSheet({
</div> </div>
<Button <Button
type="button" type="button"
variant="destructive" variant="outline"
size="sm" size="sm"
disabled={actionBusy} disabled={actionBusy}
onClick={() => setDeleteOpen(true)} onClick={() => setDeleteOpen(true)}
className="shrink-0 rounded-full" className={cn(
"h-10 w-full shrink-0 rounded-full border-destructive/20 text-destructive",
"hover:bg-destructive/8 hover:text-destructive sm:h-9 sm:w-auto",
)}
> >
<Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden /> <Trash2 className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{t("settings.skills.deleteAction", { defaultValue: "Delete" })} {t("settings.skills.deleteAction", { defaultValue: "Delete" })}
@ -571,7 +617,7 @@ function RawInstructionsBlock({ markdown }: { markdown: string }) {
return ( return (
<details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3"> <details className="group rounded-[18px] border border-border/45 bg-muted/20 px-3 py-3">
<summary className="flex cursor-pointer select-none items-center justify-between gap-3 text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground"> <summary className="flex min-h-11 cursor-pointer select-none items-center justify-between gap-3 text-[13px] font-medium text-foreground/90 transition-colors hover:text-foreground">
<span> <span>
{t("settings.skills.instructionsTitle", { defaultValue: "Skill instructions" })} {t("settings.skills.instructionsTitle", { defaultValue: "Skill instructions" })}
</span> </span>
@ -657,7 +703,7 @@ function RequirementsSection({
})} })}
title={option.label} title={option.label}
onClick={() => void copyCommand(option.command)} onClick={() => void copyCommand(option.command)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[8px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[9px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:h-7 sm:w-7"
> >
{copiedCommand === option.command ? ( {copiedCommand === option.command ? (
<Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden /> <Check className="h-3.5 w-3.5 text-emerald-600" aria-hidden />
@ -689,7 +735,7 @@ function RequirementsSection({
variant="outline" variant="outline"
size="sm" size="sm"
onClick={onRefresh} onClick={onRefresh}
className="mt-3 h-8 rounded-full bg-background/60 px-3 text-[11px]" className="mt-3 h-9 rounded-full bg-background/60 px-3 text-[11px]"
> >
<RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden /> <RefreshCw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{t("settings.skills.checkAgain", { defaultValue: "Check again" })} {t("settings.skills.checkAgain", { defaultValue: "Check again" })}

View File

@ -198,7 +198,7 @@ export function SkillsMarketplace({ installedSkills }: { installedSkills: SkillS
{query.trim().length < 2 ? ( {query.trim().length < 2 ? (
<section className="overflow-hidden rounded-[22px] bg-settings-surface"> <section className="overflow-hidden rounded-[22px] bg-settings-surface">
<div className="flex items-center justify-between border-b border-border/45 px-5 py-4"> <div className="flex flex-col items-start gap-2 border-b border-border/45 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-5">
<div> <div>
<h2 className="text-[14px] font-semibold"> <h2 className="text-[14px] font-semibold">
{t("settings.skills.marketplaceTrendingTitle", { {t("settings.skills.marketplaceTrendingTitle", {
@ -371,9 +371,9 @@ function MarketplaceSkillRow({
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex min-w-0 items-center gap-3 px-2 py-3.5"> <div className="flex min-w-0 items-center gap-2 px-1 py-3.5 sm:gap-3 sm:px-2">
{skill.rank ? ( {skill.rank ? (
<span className="w-7 shrink-0 text-right font-mono text-[12px] tabular-nums text-muted-foreground/65"> <span className="w-6 shrink-0 text-right font-mono text-[11px] tabular-nums text-muted-foreground/65 sm:w-7 sm:text-[12px]">
#{skill.rank} #{skill.rank}
</span> </span>
) : null} ) : null}
@ -390,7 +390,7 @@ function MarketplaceSkillRow({
name: skill.name, name: skill.name,
defaultValue: "Open {{name}} on skills.sh", defaultValue: "Open {{name}} on skills.sh",
})} })}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground" className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
> >
<ExternalLink className="h-3.5 w-3.5" aria-hidden /> <ExternalLink className="h-3.5 w-3.5" aria-hidden />
</a> </a>
@ -418,7 +418,10 @@ function MarketplaceSkillRow({
variant={installed ? "secondary" : "default"} variant={installed ? "secondary" : "default"}
disabled={installed || installBusy || installSupported === false} disabled={installed || installBusy || installSupported === false}
onClick={() => onSelect(skill)} onClick={() => onSelect(skill)}
className={cn("min-w-[92px] rounded-full", installed && "text-emerald-700")} className={cn(
"min-w-[82px] rounded-full px-2.5 sm:min-w-[92px] sm:px-3",
installed && "text-emerald-700",
)}
title={ title={
installSupported === false installSupported === false
? t("settings.skills.marketplaceNpxRequired", { ? t("settings.skills.marketplaceNpxRequired", {
@ -512,7 +515,7 @@ function TrendingSkeleton() {
<div className="h-3.5 w-48 max-w-[55%] animate-pulse rounded bg-muted" /> <div className="h-3.5 w-48 max-w-[55%] animate-pulse rounded bg-muted" />
<div className="h-3 w-32 max-w-[40%] animate-pulse rounded bg-muted/70" /> <div className="h-3 w-32 max-w-[40%] animate-pulse rounded bg-muted/70" />
</div> </div>
<div className="h-8 w-[92px] animate-pulse rounded-full bg-muted" /> <div className="h-8 w-[82px] animate-pulse rounded-full bg-muted sm:w-[92px]" />
</div> </div>
))} ))}
</div> </div>

View File

@ -57,13 +57,24 @@ const sheetVariants = cva(
interface SheetContentProps interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>, extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
VariantProps<typeof sheetVariants> { VariantProps<typeof sheetVariants> {
closeButtonClassName?: string;
showCloseButton?: boolean; showCloseButton?: boolean;
} }
const SheetContent = React.forwardRef< const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>, React.ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps SheetContentProps
>(({ side = "right", className, children, showCloseButton = true, ...props }, ref) => ( >((
{
side = "right",
className,
children,
closeButtonClassName,
showCloseButton = true,
...props
},
ref,
) => (
<SheetPortal> <SheetPortal>
<SheetOverlay /> <SheetOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
@ -78,7 +89,14 @@ const SheetContent = React.forwardRef<
> >
{children} {children}
{showCloseButton ? ( {showCloseButton ? (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"> <DialogPrimitive.Close
className={cn(
"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity",
"hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"disabled:pointer-events-none",
closeButtonClassName,
)}
>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>

View File

@ -378,6 +378,12 @@ describe("App layout", () => {
}); });
it("opens Skills from the main sidebar", async () => { it("opens Skills from the main sidebar", async () => {
const longSkillDescription = [
"Work with GitHub repositories, issues, pull requests, releases, workflows,",
"and code search through the GitHub CLI.",
"Use this skill for repository maintenance, review automation, release preparation,",
"and other GitHub workflows that need authenticated command-line access.",
].join(" ");
mockFetchRoutes({ mockFetchRoutes({
"/api/settings": baseSettingsPayload(), "/api/settings": baseSettingsPayload(),
"/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" }, "/api/settings/cli-apps": { apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" },
@ -413,7 +419,7 @@ describe("App layout", () => {
}, },
"/api/webui/skills/github": { "/api/webui/skills/github": {
name: "github", name: "github",
description: "Work with GitHub.", description: longSkillDescription,
source: "builtin", source: "builtin",
enabled: true, enabled: true,
deletable: false, deletable: false,
@ -501,6 +507,13 @@ describe("App layout", () => {
fireEvent.click(screen.getByRole("button", { name: "Open details for github" })); fireEvent.click(screen.getByRole("button", { name: "Open details for github" }));
expect(await screen.findByRole("heading", { name: "github" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "github" })).toBeInTheDocument();
const showMore = await screen.findByRole("button", { name: "Show more" });
expect(showMore).toHaveAttribute("aria-expanded", "false");
fireEvent.click(showMore);
expect(screen.getByRole("button", { name: "Show less" })).toHaveAttribute(
"aria-expanded",
"true",
);
expect(screen.getByText("Setup required")).toBeInTheDocument(); expect(screen.getByText("Setup required")).toBeInTheDocument();
expect(screen.getByText("brew install gh")).toBeInTheDocument(); expect(screen.getByText("brew install gh")).toBeInTheDocument();
expect(screen.queryByText("Unavailable reason")).not.toBeInTheDocument(); expect(screen.queryByText("Unavailable reason")).not.toBeInTheDocument();