Skip to content
Merged
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
309 changes: 174 additions & 135 deletions apps/web/src/components/settings/SkillsSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { normalizeSkillVisibilityPath } from "@t3tools/client-runtime/providerSkills";
import { CopyIcon, EyeIcon, FolderOpenIcon, RefreshCwIcon } from "lucide-react";
import {
CopyIcon,
EyeIcon,
FolderOpenIcon,
PlusIcon,
SearchIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useEffect, useState } from "react";

import { useProjectFileQuery } from "../files/projectFilesQueryState";
Expand All @@ -12,9 +19,11 @@ import { useAtomCommand } from "../../state/use-atom-command";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group";
import { RefreshIcon } from "../ui/refresh-icon";
import { toastManager } from "../ui/toast";
import { SettingsPageContainer } from "./settingsLayout";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
import { useSettingsScope } from "./SettingsScopeContext";
import {
discoverLocalSkillFiles,
Expand Down Expand Up @@ -109,158 +118,188 @@ export function SkillsSettings() {

return (
<SettingsPageContainer width="wide">
<section className="flex flex-col gap-2 px-3 sm:px-4">
<h1 className="text-2xl font-semibold">Skills</h1>
<p className="text-sm text-muted-foreground">
File skills in your home folders and selected projects.
</p>
</section>
<div className="flex items-center gap-3 px-3 sm:px-4">
<span className="shrink-0 text-sm tabular-nums text-muted-foreground">
{filtered.length} {filtered.length === 1 ? "skill" : "skills"}
</span>
<Input
nativeInput
type="search"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
placeholder="Filter"
aria-label="Filter skills"
className="min-w-0 flex-1"
/>
<Button
type="button"
size="icon-sm"
variant="ghost"
aria-label="Refresh skills"
disabled={refreshing}
onClick={() => setRevision((value) => value + 1)}
>
<RefreshCwIcon className={refreshing ? "animate-spin" : undefined} />
</Button>
<Button
type="button"
variant="outline"
size="sm"
disabled={targets.length === 0}
onClick={() => setAdding(true)}
>
Add skill
</Button>
</div>
{errors.length > 0 && (
<p role="alert" className="px-4 text-sm text-destructive">
Some skills could not be loaded. {errors.join("; ")}
</p>
)}
<p className="px-4 text-xs text-muted-foreground">
Switches show or hide skills in this client's T3 skill pickers. Providers manage automatic
skill discovery.
</p>
<div className="overflow-hidden rounded-xl border">
<SettingsSection
title="Skills"
headerAction={
<div className="flex items-center gap-1.5">
<span className="text-2xs tabular-nums text-muted-foreground">
{filtered.length} {filtered.length === 1 ? "skill" : "skills"}
</span>
<InputGroup className="w-48">
<InputGroupAddon>
<SearchIcon aria-hidden className="size-3" />
</InputGroupAddon>
<InputGroupInput
type="search"
size="sm"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") setQuery("");
}}
placeholder="Search skills"
aria-label="Search skills"
/>
</InputGroup>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
size="icon-xs"
variant="ghost-muted"
aria-label="Refresh skills"
disabled={refreshing}
onClick={() => setRevision((value) => value + 1)}
>
<RefreshIcon refreshing={refreshing} />
</Button>
}
/>
<TooltipPopup side="top">Refresh skills</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
size="icon-xs"
variant="ghost-muted"
aria-label="Add skill"
disabled={targets.length === 0}
onClick={() => setAdding(true)}
>
<PlusIcon />
</Button>
}
/>
<TooltipPopup side="top">Add skill</TooltipPopup>
</Tooltip>
</div>
}
>
{errors.map((error) => (
<p
key={error}
role="alert"
className="flex items-center gap-2 px-4 py-2.5 text-xs text-warning-foreground"
>
<TriangleAlertIcon aria-hidden className="size-3.5 shrink-0 text-warning" />
{error}
</p>
))}
{filtered.length === 0 ? (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
{refreshing
? "Loading skills..."
: normalizedQuery
? "No matching skills"
: "No file skills found for this selection"}
: "No skills in your home folders or selected projects"}
</p>
) : (
filtered.map((skill) => (
<div
key={JSON.stringify([skill.environmentId, skill.path])}
className="flex flex-col gap-1 border-b px-4 py-3 last:border-b-0"
>
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
className="min-w-0 flex-1 truncate text-left text-sm font-medium hover:underline"
onClick={() => setPreview(skill)}
>
{skill.name}
</button>
<Badge variant="secondary" size="sm">
{skill.scope === "project" ? "Project" : "Personal"}
</Badge>
<span className="w-20 shrink-0 truncate text-right text-xs text-muted-foreground">
{skill.source}
</span>
<Switch
size="sm"
checked={
!skill.aliases.some((alias) =>
(hiddenByEnvironment[skill.environmentId] ?? []).some(
(hidden) =>
normalizeSkillVisibilityPath(hidden) ===
normalizeSkillVisibilityPath(alias),
),
)
}
aria-label={`Show ${skill.name} in skill pickers`}
onCheckedChange={(checked) =>
setVisible(skill.environmentId, skill.aliases, checked)
}
/>
</div>
{skill.description ? (
<p className="truncate text-sm text-muted-foreground">{skill.description}</p>
) : null}
<div className="flex min-w-0 items-center gap-1">
<p className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{skill.path}
</p>
{multipleEnvironments ? (
<span className="max-w-24 truncate text-xs text-muted-foreground">
{skill.environmentLabel}
filtered.map((skill) => {
const visible = !skill.aliases.some((alias) =>
(hiddenByEnvironment[skill.environmentId] ?? []).some(
(hidden) =>
normalizeSkillVisibilityPath(hidden) === normalizeSkillVisibilityPath(alias),
),
);
return (
<div
key={JSON.stringify([skill.environmentId, skill.path])}
className="flex flex-col gap-1 px-4 py-3"
>
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
className="min-w-0 flex-1 truncate text-left text-sm font-medium hover:underline"
onClick={() => setPreview(skill)}
>
{skill.name}
</button>
<Badge variant="secondary" size="sm">
{skill.scope === "project" ? "Project" : "Personal"}
</Badge>
<span className="w-20 shrink-0 truncate text-right text-xs text-muted-foreground">
{skill.source}
</span>
<Tooltip>
<TooltipTrigger
render={
<Switch
size="sm"
checked={visible}
aria-label={`Show ${skill.name} in skill pickers`}
onCheckedChange={(checked) =>
setVisible(skill.environmentId, skill.aliases, checked)
}
/>
}
/>
<TooltipPopup side="top">
{visible ? "Shown in skill pickers" : "Hidden from skill pickers"}
</TooltipPopup>
</Tooltip>
</div>
{skill.description ? (
<p className="truncate text-sm text-muted-foreground">{skill.description}</p>
) : null}
<Button
type="button"
size="icon-micro"
variant="ghost-muted"
aria-label={`Preview ${skill.name}`}
onClick={() => setPreview(skill)}
>
<EyeIcon />
</Button>
<Button
type="button"
size="icon-micro"
variant="ghost-muted"
aria-label={`Copy path of ${skill.name}`}
onClick={() => {
void writeTextToClipboard(skill.path).catch(() =>
toastManager.add({ type: "error", title: "Could not copy skill path" }),
);
}}
>
<CopyIcon />
</Button>
{revealableEnvironments.has(skill.environmentId) ? (
<div className="flex min-w-0 items-center gap-1">
<p className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{skill.path}
</p>
{multipleEnvironments ? (
<span className="max-w-24 truncate text-xs text-muted-foreground">
{skill.environmentLabel}
</span>
) : null}
<Button
type="button"
size="icon-micro"
variant="ghost-muted"
aria-label={`Reveal ${skill.name} in file manager`}
aria-label={`Preview ${skill.name}`}
onClick={() => setPreview(skill)}
>
<EyeIcon />
</Button>
<Button
type="button"
size="icon-micro"
variant="ghost-muted"
aria-label={`Copy path of ${skill.name}`}
onClick={() => {
void openInEditor({
environmentId: skill.environmentId,
input: { cwd: skill.path, editor: "file-manager", reveal: true },
}).then((result) => {
if (result._tag === "Failure")
toastManager.add({ type: "error", title: "Could not reveal skill" });
});
void writeTextToClipboard(skill.path).catch(() =>
toastManager.add({ type: "error", title: "Could not copy skill path" }),
);
}}
>
<FolderOpenIcon />
<CopyIcon />
</Button>
) : null}
{revealableEnvironments.has(skill.environmentId) ? (
<Button
type="button"
size="icon-micro"
variant="ghost-muted"
aria-label={`Reveal ${skill.name} in file manager`}
onClick={() => {
void openInEditor({
environmentId: skill.environmentId,
input: { cwd: skill.path, editor: "file-manager", reveal: true },
}).then((result) => {
if (result._tag === "Failure")
toastManager.add({ type: "error", title: "Could not reveal skill" });
});
}}
>
<FolderOpenIcon />
</Button>
) : null}
</div>
</div>
</div>
))
);
})
)}
</div>
</SettingsSection>
{adding && (
<AddSkillDialog
targets={targets}
Expand Down
Loading