11import { normalizeSkillVisibilityPath } from "@t3tools/client-runtime/providerSkills" ;
2- import { CopyIcon , EyeIcon , FolderOpenIcon , RefreshCwIcon } from "lucide-react" ;
2+ import {
3+ CopyIcon ,
4+ EyeIcon ,
5+ FolderOpenIcon ,
6+ PlusIcon ,
7+ SearchIcon ,
8+ TriangleAlertIcon ,
9+ } from "lucide-react" ;
310import { useEffect , useState } from "react" ;
411
512import { useProjectFileQuery } from "../files/projectFilesQueryState" ;
@@ -12,9 +19,11 @@ import { useAtomCommand } from "../../state/use-atom-command";
1219import { Badge } from "../ui/badge" ;
1320import { Button } from "../ui/button" ;
1421import { Dialog , DialogHeader , DialogPopup , DialogTitle } from "../ui/dialog" ;
15- import { Input } from "../ui/input" ;
22+ import { InputGroup , InputGroupAddon , InputGroupInput } from "../ui/input-group" ;
23+ import { RefreshIcon } from "../ui/refresh-icon" ;
1624import { toastManager } from "../ui/toast" ;
17- import { SettingsPageContainer } from "./settingsLayout" ;
25+ import { Tooltip , TooltipPopup , TooltipTrigger } from "../ui/tooltip" ;
26+ import { SettingsPageContainer , SettingsSection } from "./settingsLayout" ;
1827import { useSettingsScope } from "./SettingsScopeContext" ;
1928import {
2029 discoverLocalSkillFiles ,
@@ -109,158 +118,188 @@ export function SkillsSettings() {
109118
110119 return (
111120 < SettingsPageContainer width = "wide" >
112- < section className = "flex flex-col gap-2 px-3 sm:px-4" >
113- < h1 className = "text-2xl font-semibold" > Skills</ h1 >
114- < p className = "text-sm text-muted-foreground" >
115- File skills in your home folders and selected projects.
116- </ p >
117- </ section >
118- < div className = "flex items-center gap-3 px-3 sm:px-4" >
119- < span className = "shrink-0 text-sm tabular-nums text-muted-foreground" >
120- { filtered . length } { filtered . length === 1 ? "skill" : "skills" }
121- </ span >
122- < Input
123- nativeInput
124- type = "search"
125- value = { query }
126- onChange = { ( event ) => setQuery ( event . currentTarget . value ) }
127- placeholder = "Filter"
128- aria-label = "Filter skills"
129- className = "min-w-0 flex-1"
130- />
131- < Button
132- type = "button"
133- size = "icon-sm"
134- variant = "ghost"
135- aria-label = "Refresh skills"
136- disabled = { refreshing }
137- onClick = { ( ) => setRevision ( ( value ) => value + 1 ) }
138- >
139- < RefreshCwIcon className = { refreshing ? "animate-spin" : undefined } />
140- </ Button >
141- < Button
142- type = "button"
143- variant = "outline"
144- size = "sm"
145- disabled = { targets . length === 0 }
146- onClick = { ( ) => setAdding ( true ) }
147- >
148- Add skill
149- </ Button >
150- </ div >
151- { errors . length > 0 && (
152- < p role = "alert" className = "px-4 text-sm text-destructive" >
153- Some skills could not be loaded. { errors . join ( "; " ) }
154- </ p >
155- ) }
156- < p className = "px-4 text-xs text-muted-foreground" >
157- Switches show or hide skills in this client's T3 skill pickers. Providers manage automatic
158- skill discovery.
159- </ p >
160- < div className = "overflow-hidden rounded-xl border" >
121+ < SettingsSection
122+ title = "Skills"
123+ headerAction = {
124+ < div className = "flex items-center gap-1.5" >
125+ < span className = "text-2xs tabular-nums text-muted-foreground" >
126+ { filtered . length } { filtered . length === 1 ? "skill" : "skills" }
127+ </ span >
128+ < InputGroup className = "w-48" >
129+ < InputGroupAddon >
130+ < SearchIcon aria-hidden className = "size-3" />
131+ </ InputGroupAddon >
132+ < InputGroupInput
133+ type = "search"
134+ size = "sm"
135+ value = { query }
136+ onChange = { ( event ) => setQuery ( event . currentTarget . value ) }
137+ onKeyDown = { ( event ) => {
138+ if ( event . key === "Escape" ) setQuery ( "" ) ;
139+ } }
140+ placeholder = "Search skills"
141+ aria-label = "Search skills"
142+ />
143+ </ InputGroup >
144+ < Tooltip >
145+ < TooltipTrigger
146+ render = {
147+ < Button
148+ type = "button"
149+ size = "icon-xs"
150+ variant = "ghost-muted"
151+ aria-label = "Refresh skills"
152+ disabled = { refreshing }
153+ onClick = { ( ) => setRevision ( ( value ) => value + 1 ) }
154+ >
155+ < RefreshIcon refreshing = { refreshing } />
156+ </ Button >
157+ }
158+ />
159+ < TooltipPopup side = "top" > Refresh skills</ TooltipPopup >
160+ </ Tooltip >
161+ < Tooltip >
162+ < TooltipTrigger
163+ render = {
164+ < Button
165+ type = "button"
166+ size = "icon-xs"
167+ variant = "ghost-muted"
168+ aria-label = "Add skill"
169+ disabled = { targets . length === 0 }
170+ onClick = { ( ) => setAdding ( true ) }
171+ >
172+ < PlusIcon />
173+ </ Button >
174+ }
175+ />
176+ < TooltipPopup side = "top" > Add skill</ TooltipPopup >
177+ </ Tooltip >
178+ </ div >
179+ }
180+ >
181+ { errors . map ( ( error ) => (
182+ < p
183+ key = { error }
184+ role = "alert"
185+ className = "flex items-center gap-2 px-4 py-2.5 text-xs text-warning-foreground"
186+ >
187+ < TriangleAlertIcon aria-hidden className = "size-3.5 shrink-0 text-warning" />
188+ { error }
189+ </ p >
190+ ) ) }
161191 { filtered . length === 0 ? (
162192 < p className = "px-4 py-8 text-center text-sm text-muted-foreground" >
163193 { refreshing
164194 ? "Loading skills..."
165195 : normalizedQuery
166196 ? "No matching skills"
167- : "No file skills found for this selection " }
197+ : "No skills in your home folders or selected projects " }
168198 </ p >
169199 ) : (
170- filtered . map ( ( skill ) => (
171- < div
172- key = { JSON . stringify ( [ skill . environmentId , skill . path ] ) }
173- className = "flex flex-col gap-1 border-b px-4 py-3 last:border-b-0"
174- >
175- < div className = "flex min-w-0 items-center gap-2" >
176- < button
177- type = "button"
178- className = "min-w-0 flex-1 truncate text-left text-sm font-medium hover:underline"
179- onClick = { ( ) => setPreview ( skill ) }
180- >
181- { skill . name }
182- </ button >
183- < Badge variant = "secondary" size = "sm" >
184- { skill . scope === "project" ? "Project" : "Personal" }
185- </ Badge >
186- < span className = "w-20 shrink-0 truncate text-right text-xs text-muted-foreground" >
187- { skill . source }
188- </ span >
189- < Switch
190- size = "sm"
191- checked = {
192- ! skill . aliases . some ( ( alias ) =>
193- ( hiddenByEnvironment [ skill . environmentId ] ?? [ ] ) . some (
194- ( hidden ) =>
195- normalizeSkillVisibilityPath ( hidden ) ===
196- normalizeSkillVisibilityPath ( alias ) ,
197- ) ,
198- )
199- }
200- aria-label = { `Show ${ skill . name } in skill pickers` }
201- onCheckedChange = { ( checked ) =>
202- setVisible ( skill . environmentId , skill . aliases , checked )
203- }
204- />
205- </ div >
206- { skill . description ? (
207- < p className = "truncate text-sm text-muted-foreground" > { skill . description } </ p >
208- ) : null }
209- < div className = "flex min-w-0 items-center gap-1" >
210- < p className = "min-w-0 flex-1 truncate text-xs text-muted-foreground" >
211- { skill . path }
212- </ p >
213- { multipleEnvironments ? (
214- < span className = "max-w-24 truncate text-xs text-muted-foreground" >
215- { skill . environmentLabel }
200+ filtered . map ( ( skill ) => {
201+ const visible = ! skill . aliases . some ( ( alias ) =>
202+ ( hiddenByEnvironment [ skill . environmentId ] ?? [ ] ) . some (
203+ ( hidden ) =>
204+ normalizeSkillVisibilityPath ( hidden ) === normalizeSkillVisibilityPath ( alias ) ,
205+ ) ,
206+ ) ;
207+ return (
208+ < div
209+ key = { JSON . stringify ( [ skill . environmentId , skill . path ] ) }
210+ className = "flex flex-col gap-1 px-4 py-3"
211+ >
212+ < div className = "flex min-w-0 items-center gap-2" >
213+ < button
214+ type = "button"
215+ className = "min-w-0 flex-1 truncate text-left text-sm font-medium hover:underline"
216+ onClick = { ( ) => setPreview ( skill ) }
217+ >
218+ { skill . name }
219+ </ button >
220+ < Badge variant = "secondary" size = "sm" >
221+ { skill . scope === "project" ? "Project" : "Personal" }
222+ </ Badge >
223+ < span className = "w-20 shrink-0 truncate text-right text-xs text-muted-foreground" >
224+ { skill . source }
216225 </ span >
226+ < Tooltip >
227+ < TooltipTrigger
228+ render = {
229+ < Switch
230+ size = "sm"
231+ checked = { visible }
232+ aria-label = { `Show ${ skill . name } in skill pickers` }
233+ onCheckedChange = { ( checked ) =>
234+ setVisible ( skill . environmentId , skill . aliases , checked )
235+ }
236+ />
237+ }
238+ />
239+ < TooltipPopup side = "top" >
240+ { visible ? "Shown in skill pickers" : "Hidden from skill pickers" }
241+ </ TooltipPopup >
242+ </ Tooltip >
243+ </ div >
244+ { skill . description ? (
245+ < p className = "truncate text-sm text-muted-foreground" > { skill . description } </ p >
217246 ) : null }
218- < Button
219- type = "button"
220- size = "icon-micro"
221- variant = "ghost-muted"
222- aria-label = { `Preview ${ skill . name } ` }
223- onClick = { ( ) => setPreview ( skill ) }
224- >
225- < EyeIcon />
226- </ Button >
227- < Button
228- type = "button"
229- size = "icon-micro"
230- variant = "ghost-muted"
231- aria-label = { `Copy path of ${ skill . name } ` }
232- onClick = { ( ) => {
233- void writeTextToClipboard ( skill . path ) . catch ( ( ) =>
234- toastManager . add ( { type : "error" , title : "Could not copy skill path" } ) ,
235- ) ;
236- } }
237- >
238- < CopyIcon />
239- </ Button >
240- { revealableEnvironments . has ( skill . environmentId ) ? (
247+ < div className = "flex min-w-0 items-center gap-1" >
248+ < p className = "min-w-0 flex-1 truncate text-xs text-muted-foreground" >
249+ { skill . path }
250+ </ p >
251+ { multipleEnvironments ? (
252+ < span className = "max-w-24 truncate text-xs text-muted-foreground" >
253+ { skill . environmentLabel }
254+ </ span >
255+ ) : null }
241256 < Button
242257 type = "button"
243258 size = "icon-micro"
244259 variant = "ghost-muted"
245- aria-label = { `Reveal ${ skill . name } in file manager` }
260+ aria-label = { `Preview ${ skill . name } ` }
261+ onClick = { ( ) => setPreview ( skill ) }
262+ >
263+ < EyeIcon />
264+ </ Button >
265+ < Button
266+ type = "button"
267+ size = "icon-micro"
268+ variant = "ghost-muted"
269+ aria-label = { `Copy path of ${ skill . name } ` }
246270 onClick = { ( ) => {
247- void openInEditor ( {
248- environmentId : skill . environmentId ,
249- input : { cwd : skill . path , editor : "file-manager" , reveal : true } ,
250- } ) . then ( ( result ) => {
251- if ( result . _tag === "Failure" )
252- toastManager . add ( { type : "error" , title : "Could not reveal skill" } ) ;
253- } ) ;
271+ void writeTextToClipboard ( skill . path ) . catch ( ( ) =>
272+ toastManager . add ( { type : "error" , title : "Could not copy skill path" } ) ,
273+ ) ;
254274 } }
255275 >
256- < FolderOpenIcon />
276+ < CopyIcon />
257277 </ Button >
258- ) : null }
278+ { revealableEnvironments . has ( skill . environmentId ) ? (
279+ < Button
280+ type = "button"
281+ size = "icon-micro"
282+ variant = "ghost-muted"
283+ aria-label = { `Reveal ${ skill . name } in file manager` }
284+ onClick = { ( ) => {
285+ void openInEditor ( {
286+ environmentId : skill . environmentId ,
287+ input : { cwd : skill . path , editor : "file-manager" , reveal : true } ,
288+ } ) . then ( ( result ) => {
289+ if ( result . _tag === "Failure" )
290+ toastManager . add ( { type : "error" , title : "Could not reveal skill" } ) ;
291+ } ) ;
292+ } }
293+ >
294+ < FolderOpenIcon />
295+ </ Button >
296+ ) : null }
297+ </ div >
259298 </ div >
260- </ div >
261- ) )
299+ ) ;
300+ } )
262301 ) }
263- </ div >
302+ </ SettingsSection >
264303 { adding && (
265304 < AddSkillDialog
266305 targets = { targets }
0 commit comments