22// get the catalog's one-click installation path. Presets and previously
33// connected custom servers share the same server-side store.
44
5+ import { reportError } from "@corbits/error-sink" ;
56import { Button , ConfirmButton , Input , toast } from "@corbits/react-ui" ;
67import {
78 CONNECTOR_REGISTRY ,
@@ -65,7 +66,7 @@ function mcpOauthConnectedReturn():
6566 return { slug, toolCount } ;
6667}
6768
68- function McpPresetCard ( {
69+ export function McpPresetCard ( {
6970 tenantId,
7071 preset,
7172 toolCount,
@@ -140,7 +141,9 @@ function McpPresetCard({
140141
141142 return (
142143 < div
143- className = "border-b border-border px-2 py-2.5"
144+ className = "plugins-catalog-card min-w-0 px-2 py-2.5"
145+ data-plugin-card
146+ data-plugin-name = { preset . displayName }
144147 data-plugin-slug = { preset . slug }
145148 >
146149 < div className = "flex min-h-11 min-w-0 items-center gap-3" >
@@ -161,32 +164,37 @@ function McpPresetCard({
161164 </ span >
162165 ) : null }
163166 </ div >
164- < span className = "hidden shrink-0 text-xs text-muted-foreground xl:block" >
165- { status }
166- </ span >
167- { preset . connected ? (
168- < ConfirmButton
169- variant = "destructive"
170- size = "sm"
171- confirmLabel = "Disconnect"
172- disabled = { busy }
173- aria-label = { `Disconnect ${ preset . displayName } ` }
174- onConfirm = { handleDisconnect }
175- >
176- { busy ? "Disconnecting…" : "Disconnect" }
177- </ ConfirmButton >
178- ) : tokenFieldOpen ? null : (
179- < Button
180- type = "button"
181- size = "sm"
182- variant = "ghost"
183- disabled = { busy }
184- aria-label = { `Connect ${ preset . displayName } ` }
185- onClick = { handleConnect }
186- >
187- { busy ? "Connecting…" : "Connect" }
188- </ Button >
189- ) }
167+ < div className = "plugins-catalog-card-actions" >
168+ < span className = "text-xs text-muted-foreground" > { status } </ span >
169+ { preset . connected ? (
170+ < ConfirmButton
171+ variant = "ghost"
172+ size = "sm"
173+ confirmLabel = {
174+ < >
175+ Disconnect
176+ < span className = "sr-only" > { preset . displayName } </ span >
177+ </ >
178+ }
179+ disabled = { busy }
180+ onConfirm = { handleDisconnect }
181+ >
182+ { busy ? "Disconnecting…" : "Manage" }
183+ < span className = "sr-only" > { preset . displayName } </ span >
184+ </ ConfirmButton >
185+ ) : tokenFieldOpen ? null : (
186+ < Button
187+ type = "button"
188+ size = "sm"
189+ variant = "ghost"
190+ disabled = { busy }
191+ aria-label = { `Connect ${ preset . displayName } ` }
192+ onClick = { handleConnect }
193+ >
194+ { busy ? "Connecting…" : "Connect" }
195+ </ Button >
196+ ) }
197+ </ div >
190198 </ div >
191199 { tokenFieldOpen && ! preset . connected ? (
192200 < div className = "mt-2 flex flex-col gap-2 pl-11" >
@@ -248,22 +256,7 @@ function McpPresetCard({
248256 ) ;
249257}
250258
251- export function McpPresetCardsSection ( {
252- tenantId,
253- query = "" ,
254- autoConnectSlug = null ,
255- onAutoConnectHandled,
256- } : {
257- readonly tenantId : string ;
258- readonly query ?: string ;
259- /** A preset slug named by a `/plugins?connect=mcp:<slug>` deep link
260- * (CL-7141) — once the catalog has loaded, that preset's row gets
261- * focused so a person lands on the right card without hunting for it.
262- * Never auto-fires the connect action itself: connecting still takes
263- * a person's own click, the same as every other card here. */
264- readonly autoConnectSlug ?: string | null ;
265- readonly onAutoConnectHandled ?: ( ) => void ;
266- } ) {
259+ export function useMcpPresetCatalog ( tenantId : string ) {
267260 const [ presets , setPresets ] = useState < readonly McpPreset [ ] > ( [ ] ) ;
268261 const [ toolCounts , setToolCounts ] = useState < ReadonlyMap < string , number > > (
269262 ( ) => {
@@ -274,86 +267,43 @@ export function McpPresetCardsSection({
274267 } ,
275268 ) ;
276269 const [ loadError , setLoadError ] = useState < string | null > ( null ) ;
277- // Whether the presets fetch has resolved at least once — distinct from
278- // "zero presets": this catalog is the same ~10 curated apps for every
279- // tenant, so an empty `presets` array before this flips true is a
280- // loading gap, never a real "nothing to connect" state (CL-6472). The
281- // section used to `return null` whenever presets were empty regardless
282- // of why, which let a load-in-progress render as if the whole catalog
283- // had vanished — this component must never go quiet like that again.
284270 const [ loaded , setLoaded ] = useState ( false ) ;
271+ const [ reloadKey , setReloadKey ] = useState ( 0 ) ;
285272
286- function reload ( ) {
273+ useEffect ( ( ) => {
274+ let cancelled = false ;
275+ setLoaded ( false ) ;
287276 listMcpPresets ( tenantId )
288277 . then ( ( data ) => {
289- setPresets ( data ) ;
290- setLoadError ( null ) ;
278+ if ( ! cancelled ) {
279+ setPresets ( data ) ;
280+ setLoadError ( null ) ;
281+ }
291282 } )
292- . catch ( ( cause : unknown ) => setLoadError ( messageOf ( cause ) ) )
293- . finally ( ( ) => setLoaded ( true ) ) ;
294- }
295-
296- useEffect ( ( ) => {
297- reload ( ) ;
298- } , [ tenantId ] ) ;
283+ . catch ( ( cause : unknown ) => {
284+ reportError ( cause , {
285+ operation : "plugins.catalog.load-presets" ,
286+ tenantId,
287+ } ) ;
288+ if ( ! cancelled ) {
289+ setPresets ( [ ] ) ;
290+ setLoadError ( messageOf ( cause ) ) ;
291+ }
292+ } )
293+ . finally ( ( ) => {
294+ if ( ! cancelled ) setLoaded ( true ) ;
295+ } ) ;
296+ return ( ) => {
297+ cancelled = true ;
298+ } ;
299+ } , [ tenantId , reloadKey ] ) ;
299300
300- useEffect ( ( ) => {
301- if ( ! loaded || autoConnectSlug === null ) return ;
302- if ( presets . some ( ( preset ) => preset . slug === autoConnectSlug ) ) {
303- const row = document . querySelector (
304- `[data-plugin-slug="${ autoConnectSlug } "] button` ,
305- ) ;
306- ( row as HTMLButtonElement | null ) ?. focus ( ) ;
301+ function handleChanged ( slug : string , toolCount : number | undefined ) {
302+ if ( toolCount !== undefined ) {
303+ setToolCounts ( ( current ) => new Map ( current ) . set ( slug , toolCount ) ) ;
307304 }
308- onAutoConnectHandled ?.( ) ;
309- } , [ loaded , autoConnectSlug , presets , onAutoConnectHandled ] ) ;
310-
311- const needle = query . trim ( ) . toLowerCase ( ) ;
312- const visiblePresets = presets . filter (
313- ( preset ) =>
314- needle === "" ||
315- `${ preset . displayName } ${ preset . description } `
316- . toLowerCase ( )
317- . includes ( needle ) ,
318- ) ;
305+ setReloadKey ( ( key ) => key + 1 ) ;
306+ }
319307
320- return (
321- < section className = "flex flex-col gap-2" >
322- < h3 className = "text-xs font-semibold uppercase tracking-wide text-muted-foreground" >
323- Connect apps
324- </ h3 >
325- { loadError !== null ? (
326- < p className = "text-sm text-destructive" role = "alert" >
327- { loadError }
328- </ p >
329- ) : ! loaded ? (
330- < p className = "text-sm text-muted-foreground" > Loading…</ p >
331- ) : visiblePresets . length === 0 ? (
332- < p className = "text-sm text-muted-foreground" >
333- { presets . length === 0
334- ? "No apps to connect right now."
335- : `No app matches "${ query . trim ( ) } ".` }
336- </ p >
337- ) : (
338- < div className = "border border-border [&>*:last-child]:border-b-0" >
339- { visiblePresets . map ( ( preset ) => (
340- < McpPresetCard
341- key = { preset . slug }
342- tenantId = { tenantId }
343- preset = { preset }
344- toolCount = { toolCounts . get ( preset . slug ) }
345- onChanged = { ( toolCount ) => {
346- if ( toolCount !== undefined ) {
347- setToolCounts ( ( prev ) =>
348- new Map ( prev ) . set ( preset . slug , toolCount ) ,
349- ) ;
350- }
351- reload ( ) ;
352- } }
353- />
354- ) ) }
355- </ div >
356- ) }
357- </ section >
358- ) ;
308+ return { presets, toolCounts, loaded, loadError, handleChanged } ;
359309}
0 commit comments