* feat(react): add CardStack primitive and adopt it across surfaces Introduces a new `<CardStack>` primitive (and its Entry/Content/ Header sub-components) in packages/react/src/components/card-stack.tsx that standardizes the grouped-card layout used across the app — one container style, consistent padding, hover affordances, and slotted title/description/action regions. Adopts it in four places that were previously reinventing the same pattern with hand-rolled `<div className="rounded-lg border ...">` markup: - Secrets page: each secret row becomes a `<CardStackEntry>` with title/description/actions slots. - Schema explorer: wrapped in `<CardStack>`/`<CardStackHeader>` with a computed field count. - Tool detail: schema and TypeScript sections use CardStack headers, plus a small `EmptySection` helper for consistent empty states. - Tool tree: recursive tree rendering is flattened into a row-based layout inside a CardStack wrapper. * fix(react): use Input/Button components in CardStack search (oxlint forbid-elements) * fix(ci): unblock typecheck and openapi test from main - annotate Plan types in billing_.plans to fix implicit any - set 60s timeout on Cloudflare real-specs describe block * style: apply oxfmt formatting
Unified · Split
| @@ -0,0 +1,386 @@ | ||
| 1 | +import * as React from "react"; | |
| 2 | +import { PlusIcon, SearchIcon, XIcon } from "lucide-react"; | |
| 3 | +import { Collapsible as CollapsiblePrimitive, Slot } from "radix-ui"; | |
| 4 | + | |
| 5 | +import { cn } from "../lib/utils"; | |
| 6 | +import { Button } from "./button"; | |
| 7 | +import { Input } from "./input"; | |
| 8 | +import { Label } from "./label"; | |
| 9 | + | |
| 10 | +type CardStackContextValue = { | |
| 11 | + collapsible: boolean; | |
| 12 | + searchable: boolean; | |
| 13 | + searchQuery: string; | |
| 14 | + setSearchQuery: (query: string) => void; | |
| 15 | +}; | |
| 16 | + | |
| 17 | +const CardStackContext = React.createContext<CardStackContextValue>({ | |
| 18 | + collapsible: false, | |
| 19 | + searchable: false, | |
| 20 | + searchQuery: "", | |
| 21 | + setSearchQuery: () => {}, | |
| 22 | +}); | |
| 23 | + | |
| 24 | +type CardStackProps = React.ComponentProps<"div"> & { | |
| 25 | + collapsible?: boolean; | |
| 26 | + defaultOpen?: boolean; | |
| 27 | + open?: boolean; | |
| 28 | + onOpenChange?: (open: boolean) => void; | |
| 29 | + /** | |
| 30 | + * When true, renders a compact search input in the header and filters | |
| 31 | + * entries whose `searchText` prop does not match the query. | |
| 32 | + */ | |
| 33 | + searchable?: boolean; | |
| 34 | + searchQuery?: string; | |
| 35 | + defaultSearchQuery?: string; | |
| 36 | + onSearchChange?: (query: string) => void; | |
| 37 | +}; | |
| 38 | + | |
| 39 | +function CardStack({ | |
| 40 | + className, | |
| 41 | + collapsible = false, | |
| 42 | + defaultOpen, | |
| 43 | + open, | |
| 44 | + onOpenChange, | |
| 45 | + searchable = false, | |
| 46 | + searchQuery: searchQueryProp, | |
| 47 | + defaultSearchQuery = "", | |
| 48 | + onSearchChange, | |
| 49 | + ...props | |
| 50 | +}: CardStackProps) { | |
| 51 | + const [uncontrolledQuery, setUncontrolledQuery] = React.useState(defaultSearchQuery); | |
| 52 | + const searchQuery = searchQueryProp ?? uncontrolledQuery; | |
| 53 | + const setSearchQuery = React.useCallback( | |
| 54 | + (query: string) => { | |
| 55 | + if (searchQueryProp === undefined) setUncontrolledQuery(query); | |
| 56 | + onSearchChange?.(query); | |
| 57 | + }, | |
| 58 | + [searchQueryProp, onSearchChange], | |
| 59 | + ); | |
| 60 | + | |
| 61 | + const contextValue = React.useMemo( | |
| 62 | + () => ({ collapsible, searchable, searchQuery, setSearchQuery }), | |
| 63 | + [collapsible, searchable, searchQuery, setSearchQuery], | |
| 64 | + ); | |
| 65 | + | |
| 66 | + const card = ( | |
| 67 | + <div | |
| 68 | + data-slot="card-stack" | |
| 69 | + data-collapsible={collapsible || undefined} | |
| 70 | + className={cn( | |
| 71 | + "flex flex-col overflow-hidden rounded-lg border border-border/50 bg-card text-card-foreground focus-within:!opacity-100", | |
| 72 | + className, | |
| 73 | + )} | |
| 74 | + {...props} | |
| 75 | + /> | |
| 76 | + ); | |
| 77 | + | |
| 78 | + if (collapsible) { | |
| 79 | + return ( | |
| 80 | + <CardStackContext.Provider value={contextValue}> | |
| 81 | + <CollapsiblePrimitive.Root | |
| 82 | + defaultOpen={defaultOpen ?? true} | |
| 83 | + open={open} | |
| 84 | + onOpenChange={onOpenChange} | |
| 85 | + asChild | |
| 86 | + > | |
| 87 | + {card} | |
| 88 | + </CollapsiblePrimitive.Root> | |
| 89 | + </CardStackContext.Provider> | |
| 90 | + ); | |
| 91 | + } | |
| 92 | + | |
| 93 | + return <CardStackContext.Provider value={contextValue}>{card}</CardStackContext.Provider>; | |
| 94 | +} | |
| 95 | + | |
| 96 | +function CardStackSearchInput() { | |
| 97 | + const { searchQuery, setSearchQuery } = React.useContext(CardStackContext); | |
| 98 | + return ( | |
| 99 | + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events | |
| 100 | + <div | |
| 101 | + data-slot="card-stack-search" | |
| 102 | + className="flex shrink-0 items-center gap-1.5 rounded-md border border-input bg-background px-2 py-1 text-muted-foreground focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/40" | |
| 103 | + onClick={(event) => event.stopPropagation()} | |
| 104 | + > | |
| 105 | + <SearchIcon aria-hidden className="size-3.5 shrink-0" /> | |
| 106 | + <Input | |
| 107 | + type="text" | |
| 108 | + value={searchQuery} | |
| 109 | + onChange={(event) => setSearchQuery(event.target.value)} | |
| 110 | + placeholder="Search…" | |
| 111 | + aria-label="Search entries" | |
| 112 | + className="h-5 w-32 rounded-none border-0 bg-transparent p-0 text-xs text-foreground shadow-none outline-none placeholder:text-muted-foreground focus-visible:border-0 focus-visible:ring-0 md:text-xs dark:bg-transparent" | |
| 113 | + /> | |
| 114 | + {searchQuery && ( | |
| 115 | + <Button | |
| 116 | + type="button" | |
| 117 | + variant="ghost" | |
| 118 | + size="icon-xs" | |
| 119 | + aria-label="Clear search" | |
| 120 | + onClick={() => setSearchQuery("")} | |
| 121 | + className="size-4 rounded-sm text-muted-foreground transition-[color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:bg-transparent hover:text-foreground" | |
| 122 | + > | |
| 123 | + <XIcon aria-hidden className="size-3" /> | |
| 124 | + </Button> | |
| 125 | + )} | |
| 126 | + </div> | |
| 127 | + ); | |
| 128 | +} | |
| 129 | + | |
| 130 | +type CardStackHeaderProps = React.HTMLAttributes<HTMLElement> & { | |
| 131 | + /** | |
| 132 | + * Content rendered on the right side of the header, after the title and | |
| 133 | + * (optional) search input. Useful for action buttons like "Add Header". | |
| 134 | + */ | |
| 135 | + rightSlot?: React.ReactNode; | |
| 136 | +}; | |
| 137 | + | |
| 138 | +function CardStackHeader({ className, children, rightSlot, ...props }: CardStackHeaderProps) { | |
| 139 | + const { collapsible, searchable } = React.useContext(CardStackContext); | |
| 140 | + | |
| 141 | + const title = <span className="min-w-0 flex-1 truncate">{children}</span>; | |
| 142 | + | |
| 143 | + if (collapsible) { | |
| 144 | + return ( | |
| 145 | + <CollapsiblePrimitive.Trigger | |
| 146 | + data-slot="card-stack-header" | |
| 147 | + className={cn( | |
| 148 | + "group/card-stack-header flex w-full items-center justify-between gap-4 px-4 py-3 text-left text-sm font-medium outline-none transition-[background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:bg-accent/40 focus-visible:bg-accent/40", | |
| 149 | + className, | |
| 150 | + )} | |
| 151 | + {...props} | |
| 152 | + > | |
| 153 | + {title} | |
| 154 | + {searchable && <CardStackSearchInput />} | |
| 155 | + {rightSlot} | |
| 156 | + <PlusIcon | |
| 157 | + aria-hidden | |
| 158 | + className="size-4 shrink-0 text-muted-foreground transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] group-data-[state=open]/card-stack-header:rotate-45" | |
| 159 | + /> | |
| 160 | + </CollapsiblePrimitive.Trigger> | |
| 161 | + ); | |
| 162 | + } | |
| 163 | + | |
| 164 | + return ( | |
| 165 | + <div | |
| 166 | + data-slot="card-stack-header" | |
| 167 | + className={cn( | |
| 168 | + "flex w-full items-center justify-between gap-4 px-4 py-3 text-sm font-medium", | |
| 169 | + className, | |
| 170 | + )} | |
| 171 | + {...props} | |
| 172 | + > | |
| 173 | + {title} | |
| 174 | + {searchable && <CardStackSearchInput />} | |
| 175 | + {rightSlot} | |
| 176 | + </div> | |
| 177 | + ); | |
| 178 | +} | |
| 179 | + | |
| 180 | +function CardStackHeaderAction({ className, ...props }: React.ComponentProps<"div">) { | |
| 181 | + return ( | |
| 182 | + <div | |
| 183 | + data-slot="card-stack-header-action" | |
| 184 | + className={cn("flex shrink-0 items-center gap-1 text-muted-foreground", className)} | |
| 185 | + {...props} | |
| 186 | + /> | |
| 187 | + ); | |
| 188 | +} | |
| 189 | + | |
| 190 | +function CardStackContent({ className, ...props }: React.ComponentProps<"div">) { | |
| 191 | + const { collapsible } = React.useContext(CardStackContext); | |
| 192 | + | |
| 193 | + if (collapsible) { | |
| 194 | + return ( | |
| 195 | + <CollapsiblePrimitive.Content className="overflow-hidden transition-[height] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] data-[state=closed]:h-0 data-[state=open]:h-[var(--radix-collapsible-content-height)]"> | |
| 196 | + <div | |
| 197 | + data-slot="card-stack-content" | |
| 198 | + className={cn( | |
| 199 | + "flex flex-col border-t border-border/50 first:border-t-0", | |
| 200 | + "[&>*+*]:relative [&>*+*]:before:pointer-events-none [&>*+*]:before:absolute [&>*+*]:before:inset-x-0 [&>*+*]:before:top-0 [&>*+*]:before:h-px [&>*+*]:before:bg-border/50", | |
| 201 | + className, | |
| 202 | + )} | |
| 203 | + {...props} | |
| 204 | + /> | |
| 205 | + </CollapsiblePrimitive.Content> | |
| 206 | + ); | |
| 207 | + } | |
| 208 | + | |
| 209 | + return ( | |
| 210 | + <div | |
| 211 | + data-slot="card-stack-content" | |
| 212 | + className={cn( | |
| 213 | + "flex flex-col border-t border-border/50 first:border-t-0", | |
| 214 | + "[&>*+*]:relative [&>*+*]:before:pointer-events-none [&>*+*]:before:absolute [&>*+*]:before:inset-x-0 [&>*+*]:before:top-0 [&>*+*]:before:h-px [&>*+*]:before:bg-border/50", | |
| 215 | + className, | |
| 216 | + )} | |
| 217 | + {...props} | |
| 218 | + /> | |
| 219 | + ); | |
| 220 | +} | |
| 221 | + | |
| 222 | +type CardStackEntryProps = React.ComponentProps<"div"> & { | |
| 223 | + asChild?: boolean; | |
| 224 | + /** | |
| 225 | + * Text used to match against the parent `CardStack`'s search query when | |
| 226 | + * `searchable` is enabled. When omitted, the entry is always shown. | |
| 227 | + */ | |
| 228 | + searchText?: string; | |
| 229 | +}; | |
| 230 | + | |
| 231 | +function CardStackEntry({ className, asChild = false, searchText, ...props }: CardStackEntryProps) { | |
| 232 | + const { searchable, searchQuery } = React.useContext(CardStackContext); | |
| 233 | + | |
| 234 | + if (searchable && searchText !== undefined) { | |
| 235 | + const trimmed = searchQuery.trim().toLowerCase(); | |
| 236 | + if (trimmed.length > 0 && !searchText.toLowerCase().includes(trimmed)) { | |
| 237 | + return null; | |
| 238 | + } | |
| 239 | + } | |
| 240 | + | |
| 241 | + const Comp = asChild ? Slot.Root : "div"; | |
| 242 | + return ( | |
| 243 | + <Comp | |
| 244 | + data-slot="card-stack-entry" | |
| 245 | + className={cn( | |
| 246 | + "group/card-stack-entry flex w-full items-center gap-3 px-4 py-3 text-sm outline-none transition-[background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)]", | |
| 247 | + "focus-visible:bg-accent/40", | |
| 248 | + "[&[href]]:cursor-pointer [&[href]]:hover:bg-accent/40", | |
| 249 | + className, | |
| 250 | + )} | |
| 251 | + {...props} | |
| 252 | + /> | |
| 253 | + ); | |
| 254 | +} | |
| 255 | + | |
| 256 | +type CardStackEntryFieldProps = React.ComponentProps<"div"> & { | |
| 257 | + label?: React.ReactNode; | |
| 258 | + description?: React.ReactNode; | |
| 259 | + hint?: React.ReactNode; | |
| 260 | + labelAction?: React.ReactNode; | |
| 261 | +}; | |
| 262 | + | |
| 263 | +/** | |
| 264 | + * Form-field variant of `CardStackEntry` — stacks a label, form control, and | |
| 265 | + * optional hint vertically. Use inside a `CardStack` to render consistent | |
| 266 | + * bordered form fields. Consumers pass the form control (Input, Textarea, | |
| 267 | + * etc.) as children. | |
| 268 | + */ | |
| 269 | +function CardStackEntryField({ | |
| 270 | + className, | |
| 271 | + label, | |
| 272 | + description, | |
| 273 | + hint, | |
| 274 | + labelAction, | |
| 275 | + children, | |
| 276 | + ...props | |
| 277 | +}: CardStackEntryFieldProps) { | |
| 278 | + return ( | |
| 279 | + <div | |
| 280 | + data-slot="card-stack-entry-field" | |
| 281 | + className={cn( | |
| 282 | + "group/card-stack-entry flex w-full flex-col items-stretch gap-2 px-4 py-3 text-sm outline-none", | |
| 283 | + "focus-visible:bg-accent/40", | |
| 284 | + className, | |
| 285 | + )} | |
| 286 | + {...props} | |
| 287 | + > | |
| 288 | + {(label || labelAction) && ( | |
| 289 | + <div className="flex items-center justify-between gap-2"> | |
| 290 | + {label && ( | |
| 291 | + <Label className="text-sm font-medium"> | |
| 292 | + {label} | |
| 293 | + {description && ( | |
| 294 | + <span className="font-normal text-muted-foreground"> {description}</span> | |
| 295 | + )} | |
| 296 | + </Label> | |
| 297 | + )} | |
| 298 | + {labelAction} | |
| 299 | + </div> | |
| 300 | + )} | |
| 301 | + {children} | |
| 302 | + {hint && <p className="text-[12px] text-muted-foreground">{hint}</p>} | |
| 303 | + </div> | |
| 304 | + ); | |
| 305 | +} | |
| 306 | + | |
| 307 | +function CardStackEntryMedia({ className, ...props }: React.ComponentProps<"div">) { | |
| 308 | + return ( | |
| 309 | + <div | |
| 310 | + data-slot="card-stack-entry-media" | |
| 311 | + className={cn( | |
| 312 | + "flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground [&_img]:size-full [&_img]:rounded-[inherit] [&_img]:object-cover [&_svg:not([class*='size-'])]:size-4", | |
| 313 | + className, | |
| 314 | + )} | |
| 315 | + {...props} | |
| 316 | + /> | |
| 317 | + ); | |
| 318 | +} | |
| 319 | + | |
| 320 | +function CardStackEntryContent({ className, ...props }: React.ComponentProps<"div">) { | |
| 321 | + return ( | |
| 322 | + <div | |
| 323 | + data-slot="card-stack-entry-content" | |
| 324 | + className={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)} | |
| 325 | + {...props} | |
| 326 | + /> | |
| 327 | + ); | |
| 328 | +} | |
| 329 | + | |
| 330 | +function CardStackEntryTitle({ className, ...props }: React.ComponentProps<"div">) { | |
| 331 | + return ( | |
| 332 | + <div | |
| 333 | + data-slot="card-stack-entry-title" | |
| 334 | + className={cn("truncate text-sm font-medium leading-snug", className)} | |
| 335 | + {...props} | |
| 336 | + /> | |
| 337 | + ); | |
| 338 | +} | |
| 339 | + | |
| 340 | +function CardStackEntryDescription({ className, ...props }: React.ComponentProps<"p">) { | |
| 341 | + return ( | |
| 342 | + <p | |
| 343 | + data-slot="card-stack-entry-description" | |
| 344 | + className={cn("truncate text-xs text-muted-foreground", className)} | |
| 345 | + {...props} | |
| 346 | + /> | |
| 347 | + ); | |
| 348 | +} | |
| 349 | + | |
| 350 | +function CardStackEntryActions({ className, ...props }: React.ComponentProps<"div">) { | |
| 351 | + return ( | |
| 352 | + <div | |
| 353 | + data-slot="card-stack-entry-actions" | |
| 354 | + className={cn("flex shrink-0 items-center gap-2 text-sm text-muted-foreground", className)} | |
| 355 | + {...props} | |
| 356 | + /> | |
| 357 | + ); | |
| 358 | +} | |
| 359 | + | |
| 360 | +function CardStackEmpty({ className, ...props }: React.ComponentProps<"div">) { | |
| 361 | + return ( | |
| 362 | + <div | |
| 363 | + data-slot="card-stack-empty" | |
| 364 | + className={cn( | |
| 365 | + "flex w-full items-center justify-between gap-4 px-4 py-3 text-sm text-muted-foreground", | |
| 366 | + className, | |
| 367 | + )} | |
| 368 | + {...props} | |
| 369 | + /> | |
| 370 | + ); | |
| 371 | +} | |
| 372 | + | |
| 373 | +export { | |
| 374 | + CardStack, | |
| 375 | + CardStackHeader, | |
| 376 | + CardStackHeaderAction, | |
| 377 | + CardStackContent, | |
| 378 | + CardStackEntry, | |
| 379 | + CardStackEntryField, | |
| 380 | + CardStackEntryMedia, | |
| 381 | + CardStackEntryContent, | |
| 382 | + CardStackEntryTitle, | |
| 383 | + CardStackEntryDescription, | |
| 384 | + CardStackEntryActions, | |
| 385 | + CardStackEmpty, | |
| 386 | +}; | |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { useState, useCallback } from "react"; |
| 2 | 2 | import { ChevronRight } from "lucide-react"; |
| 3 | +import { CardStack, CardStackHeader, CardStackContent } from "./card-stack"; | |
| 3 | 4 | |
| 4 | 5 | // --------------------------------------------------------------------------- |
| 5 | 6 | // JSON Schema types (subset we render) |
| @@ -180,7 +181,7 @@ const mergeAllOf = (schemas: JsonSchema[], root: JsonSchema): JsonSchema => { · 174 unmodified lines | ||
| 180 | 181 | // Type label styling — plain text, no colored pills |
| 181 | 182 | // --------------------------------------------------------------------------- |
| 182 | 183 | |
| 183 | −const typeClasses = "font-mono text-[0.6875rem] leading-5 text-muted-foreground/50"; | |
| 184 | +const typeClasses = "font-mono text-[0.6875rem] leading-5 text-muted-foreground"; | |
| 184 | 185 | |
| 185 | 186 | // --------------------------------------------------------------------------- |
| 186 | 187 | // PropertyRow |
| @@ -231,20 +232,20 @@ function PropertyRow(props: { · 44 unmodified lines | ||
| 231 | 232 | : undefined |
| 232 | 233 | } |
| 233 | 234 | className={[ |
| 234 | − "flex items-start gap-2 py-2.5 px-3", | |
| 235 | − expandable ? "cursor-pointer hover:bg-accent/30 transition-colors" : "", | |
| 235 | + "flex items-start gap-2 py-2.5 px-4", | |
| 236 | + expandable ? "cursor-pointer hover:bg-accent/40 transition-colors" : "", | |
| 236 | 237 | ].join(" ")} |
| 237 | − style={depth > 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined} | |
| 238 | + style={depth > 0 ? { paddingLeft: `${depth * 16 + 16}px` } : undefined} | |
| 238 | 239 | > |
| 239 | 240 | {/* Chevron or dot */} |
| 240 | 241 | <div className="mt-0.5 flex size-4 shrink-0 items-center justify-center"> |
| 241 | 242 | {expandable ? ( |
| 242 | 243 | <ChevronRight |
| 243 | − className="size-3 shrink-0 text-muted-foreground/30 transition-transform duration-150" | |
| 244 | + className="size-3.5 shrink-0 text-muted-foreground transition-transform duration-150" | |
| 244 | 245 | style={open ? { transform: "rotate(90deg)" } : undefined} |
| 245 | 246 | /> |
| 246 | 247 | ) : ( |
| 247 | − <span className="size-1 rounded-full bg-muted-foreground/15" /> | |
| 248 | + <span className="size-1 rounded-full bg-muted-foreground/40" /> | |
| 248 | 249 | )} |
| 249 | 250 | </div> |
| 250 | 251 | |
| @@ -254,14 +255,14 @@ function PropertyRow(props: { · 3 unmodified lines | ||
| 254 | 255 | <p className={typeClasses}>{typeLabel}</p> |
| 255 | 256 | {!hideRequiredBadge && |
| 256 | 257 | (required ? ( |
| 257 | − <p className="text-[0.6875rem] leading-5 text-orange-600/60 dark:text-orange-400/60"> | |
| 258 | + <p className="text-[0.6875rem] leading-5 font-medium text-orange-600 dark:text-orange-400"> | |
| 258 | 259 | required |
| 259 | 260 | </p> |
| 260 | 261 | ) : ( |
| 261 | − <p className="text-[0.6875rem] leading-5 text-muted-foreground/25">optional</p> | |
| 262 | + <p className="text-[0.6875rem] leading-5 text-muted-foreground">optional</p> | |
| 262 | 263 | ))} |
| 263 | 264 | {schema.default !== undefined && ( |
| 264 | − <p className="text-[0.6875rem] leading-5 text-muted-foreground/30"> | |
| 265 | + <p className="text-[0.6875rem] leading-5 text-muted-foreground"> | |
| 265 | 266 | = {JSON.stringify(schema.default)} |
| 266 | 267 | </p> |
| 267 | 268 | )} |
| @@ -271,8 +272,8 @@ function PropertyRow(props: { · 3 unmodified lines | ||
| 271 | 272 | {/* Description — below the row */} |
| 272 | 273 | {description && ( |
| 273 | 274 | <p |
| 274 | − className="px-3 pb-2 text-[0.8125rem] leading-5 text-muted-foreground/50" | |
| 275 | − style={{ paddingLeft: `${depth * 16 + 32}px` }} | |
| 275 | + className="pr-4 pb-2.5 text-[0.8125rem] leading-5 text-muted-foreground" | |
| 276 | + style={{ paddingLeft: `${depth * 16 + 36}px` }} | |
| 276 | 277 | > |
| 277 | 278 | {description} |
| 278 | 279 | </p> |
| @@ -280,7 +281,7 @@ function PropertyRow(props: { · 1 unmodified line | ||
| 280 | 281 | |
| 281 | 282 | {/* Children — rendered lazily on expand */} |
| 282 | 283 | {open && expandable && ( |
| 283 | − <div className="border-l border-border/30" style={{ marginLeft: `${depth * 16 + 20}px` }}> | |
| 284 | + <div className="border-l border-border/60" style={{ marginLeft: `${depth * 16 + 22}px` }}> | |
| 284 | 285 | <PropertyChildren schema={childSchema} root={root} depth={depth + 1} /> |
| 285 | 286 | </div> |
| 286 | 287 | )} |
| @@ -297,7 +298,7 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: · 10 unmodified lines | ||
| 297 | 298 | |
| 298 | 299 | if (depth > 6) { |
| 299 | 300 | return ( |
| 300 | − <p className="px-3 py-2 text-[0.8125rem] text-muted-foreground/30"> | |
| 301 | + <p className="px-4 py-2 text-[0.8125rem] text-muted-foreground"> | |
| 301 | 302 | Nested too deep to display. |
| 302 | 303 | </p> |
| 303 | 304 | ); |
| @@ -316,7 +317,7 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: · 12 unmodified lines | ||
| 316 | 317 | return a.localeCompare(b); |
| 317 | 318 | }); |
| 318 | 319 | return ( |
| 319 | − <div className="divide-y divide-border/20"> | |
| 320 | + <div className="divide-y divide-border/50"> | |
| 320 | 321 | {entries.map(([key, value], i) => ( |
| 321 | 322 | <PropertyRow |
| 322 | 323 | key={key} |
| @@ -356,12 +357,12 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: · 33 unmodified lines | ||
| 356 | 357 | return ( |
| 357 | 358 | <div> |
| 358 | 359 | <p |
| 359 | − className="px-3 py-2 text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/30" | |
| 360 | − style={depth > 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined} | |
| 360 | + className="px-4 py-2 text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground" | |
| 361 | + style={depth > 0 ? { paddingLeft: `${depth * 16 + 16}px` } : undefined} | |
| 361 | 362 | > |
| 362 | 363 | {label} |
| 363 | 364 | </p> |
| 364 | − <div className="divide-y divide-border/20"> | |
| 365 | + <div className="divide-y divide-border/50"> | |
| 365 | 366 | {variants.map((variant, i) => ( |
| 366 | 367 | <PropertyRow |
| 367 | 368 | key={i} |
| @@ -394,30 +395,69 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: · 26 unmodified lines | ||
| 394 | 395 | return null; |
| 395 | 396 | } |
| 396 | 397 | |
| 398 | +// --------------------------------------------------------------------------- | |
| 399 | +// Count top-level fields for the header subtitle | |
| 400 | +// --------------------------------------------------------------------------- | |
| 401 | + | |
| 402 | +const countTopLevelFields = (schema: JsonSchema): number => { | |
| 403 | + const resolved = deepResolve(schema, schema); | |
| 404 | + if (resolved.properties) return Object.keys(resolved.properties).length; | |
| 405 | + if (resolved.allOf) { | |
| 406 | + const merged = mergeAllOf(resolved.allOf, schema); | |
| 407 | + return merged.properties ? Object.keys(merged.properties).length : 0; | |
| 408 | + } | |
| 409 | + if (resolved.oneOf && resolved.oneOf.length > 1) return resolved.oneOf.length; | |
| 410 | + if (resolved.anyOf && resolved.anyOf.length > 1) return resolved.anyOf.length; | |
| 411 | + return 0; | |
| 412 | +}; | |
| 413 | + | |
| 397 | 414 | // --------------------------------------------------------------------------- |
| 398 | 415 | // SchemaExplorer — main export |
| 399 | 416 | // --------------------------------------------------------------------------- |
| 400 | 417 | |
| 401 | −export function SchemaExplorer(props: { schema: unknown }) { | |
| 418 | +export function SchemaExplorer(props: { schema: unknown; title?: string }) { | |
| 402 | 419 | const schema = props.schema as JsonSchema | undefined; |
| 403 | 420 | if (!schema) return null; |
| 404 | 421 | |
| 405 | 422 | const hasContent = isExpandable(schema, schema); |
| 423 | + const title = props.title; | |
| 406 | 424 | |
| 407 | 425 | if (!hasContent) { |
| 408 | 426 | const typeLabel = getTypeLabel(schema, schema); |
| 409 | 427 | return ( |
| 410 | − <div className="rounded-lg border border-border/40 px-4 py-3"> | |
| 411 | − <p className="text-sm text-muted-foreground/50"> | |
| 412 | − <span className={typeClasses}>{typeLabel}</span> | |
| 413 | − </p> | |
| 414 | − </div> | |
| 428 | + <CardStack> | |
| 429 | + {title && <CardStackHeader>{title}</CardStackHeader>} | |
| 430 | + <CardStackContent> | |
| 431 | + <div className="flex items-center gap-2 px-4 py-3"> | |
| 432 | + <span className={typeClasses}>{typeLabel}</span> | |
| 433 | + </div> | |
| 434 | + </CardStackContent> | |
| 435 | + </CardStack> | |
| 415 | 436 | ); |
| 416 | 437 | } |
| 417 | 438 | |
| 439 | + const fieldCount = countTopLevelFields(schema); | |
| 440 | + const countLabel = | |
| 441 | + fieldCount > 0 ? `${fieldCount} ${fieldCount === 1 ? "field" : "fields"}` : null; | |
| 442 | + | |
| 418 | 443 | return ( |
| 419 | − <div className="rounded-lg border border-border/40 overflow-hidden divide-y divide-border/20"> | |
| 420 | − <PropertyChildren schema={schema} root={schema} depth={0} /> | |
| 421 | − </div> | |
| 444 | + <CardStack> | |
| 445 | + {title && ( | |
| 446 | + <CardStackHeader | |
| 447 | + rightSlot={ | |
| 448 | + countLabel ? ( | |
| 449 | + <span className="shrink-0 tabular-nums text-[11px] text-muted-foreground"> | |
| 450 | + {countLabel} | |
| 451 | + </span> | |
| 452 | + ) : undefined | |
| 453 | + } | |
| 454 | + > | |
| 455 | + {title} | |
| 456 | + </CardStackHeader> | |
| 457 | + )} | |
| 458 | + <CardStackContent> | |
| 459 | + <PropertyChildren schema={schema} root={schema} depth={0} /> | |
| 460 | + </CardStackContent> | |
| 461 | + </CardStack> | |
| 422 | 462 | ); |
| 423 | 463 | } |
| @@ -6,8 +6,20 @@ import { Button } from "./button"; · 5 unmodified lines | ||
| 6 | 6 | import { Markdown } from "./markdown"; |
| 7 | 7 | import { SchemaExplorer } from "./schema-explorer"; |
| 8 | 8 | import { ExpandableCodeBlock } from "./expandable-code-block"; |
| 9 | +import { CardStack, CardStackHeader, CardStackContent } from "./card-stack"; | |
| 9 | 10 | import { Copy, Check, ChevronRight } from "lucide-react"; |
| 10 | 11 | |
| 12 | +function EmptySection(props: { title: string; message: string }) { | |
| 13 | + return ( | |
| 14 | + <CardStack> | |
| 15 | + <CardStackHeader>{props.title}</CardStackHeader> | |
| 16 | + <CardStackContent> | |
| 17 | + <p className="px-4 py-3 text-sm text-muted-foreground">{props.message}</p> | |
| 18 | + </CardStackContent> | |
| 19 | + </CardStack> | |
| 20 | + ); | |
| 21 | +} | |
| 22 | + | |
| 11 | 23 | // --------------------------------------------------------------------------- |
| 12 | 24 | // Copy button |
| 13 | 25 | // --------------------------------------------------------------------------- |
| @@ -92,7 +104,7 @@ export function ToolDetail(props: { · 78 unmodified lines | ||
| 92 | 104 | <div className="shrink-0 border-b border-border/40"> |
| 93 | 105 | <div className="px-5 pt-4 pb-0"> |
| 94 | 106 | {crumbs.length > 1 && ( |
| 95 | − <div className="flex items-center gap-1 text-sm text-muted-foreground/40"> | |
| 107 | + <div className="flex items-center gap-1 text-sm text-muted-foreground"> | |
| 96 | 108 | {crumbs.slice(0, -1).map((part, i) => ( |
| 97 | 109 | <span key={i} className="flex items-center gap-1"> |
| 98 | 110 | {i > 0 && <ChevronRight className="size-3 shrink-0" />} |
| @@ -106,7 +118,7 @@ export function ToolDetail(props: { · 7 unmodified lines | ||
| 106 | 118 | <CopyButton text={props.toolId} label="Copy tool ID" /> |
| 107 | 119 | </div> |
| 108 | 120 | {props.toolDescription && ( |
| 109 | − <div className="mt-1.5 max-w-lg text-sm text-muted-foreground/70 line-clamp-2"> | |
| 121 | + <div className="mt-1.5 max-w-lg text-sm text-muted-foreground line-clamp-2"> | |
| 110 | 122 | <Markdown>{props.toolDescription}</Markdown> |
| 111 | 123 | </div> |
| 112 | 124 | )} |
| @@ -122,7 +134,7 @@ export function ToolDetail(props: { · 9 unmodified lines | ||
| 122 | 134 | "border-b-2 pb-2.5 text-sm font-medium transition-colors rounded-none", |
| 123 | 135 | tab === "schema" |
| 124 | 136 | ? "border-primary text-foreground" |
| 125 | − : "border-transparent text-muted-foreground/50 hover:text-muted-foreground", | |
| 137 | + : "border-transparent text-muted-foreground hover:text-foreground", | |
| 126 | 138 | ].join(" ")} |
| 127 | 139 | > |
| 128 | 140 | Schema |
| @@ -136,7 +148,7 @@ export function ToolDetail(props: { · 7 unmodified lines | ||
| 136 | 148 | "border-b-2 pb-2.5 text-sm font-medium transition-colors rounded-none", |
| 137 | 149 | tab === "typescript" |
| 138 | 150 | ? "border-primary text-foreground" |
| 139 | − : "border-transparent text-muted-foreground/50 hover:text-muted-foreground", | |
| 151 | + : "border-transparent text-muted-foreground hover:text-foreground", | |
| 140 | 152 | ].join(" ")} |
| 141 | 153 | > |
| 142 | 154 | TypeScript |
| @@ -152,66 +164,48 @@ export function ToolDetail(props: { · 9 unmodified lines | ||
| 152 | 164 | onFailure: () => <div className="p-5 text-sm text-destructive">Something went wrong</div>, |
| 153 | 165 | onSuccess: () => |
| 154 | 166 | tab === "schema" ? ( |
| 155 | − <div className="px-5 py-5 space-y-6"> | |
| 156 | − <section> | |
| 157 | − <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> | |
| 158 | − Parameters | |
| 159 | − </h4> | |
| 160 | − <div className="mt-2.5"> | |
| 161 | − {data?.inputSchema ? ( | |
| 162 | − <SchemaExplorer schema={data.inputSchema} /> | |
| 163 | − ) : ( | |
| 164 | − <p className="py-2 text-sm text-muted-foreground/40">None</p> | |
| 165 | − )} | |
| 166 | − </div> | |
| 167 | − </section> | |
| 168 | − | |
| 169 | − <section> | |
| 170 | − <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> | |
| 171 | − Response | |
| 172 | − </h4> | |
| 173 | − <div className="mt-2.5"> | |
| 174 | − {data?.outputSchema ? ( | |
| 175 | − <SchemaExplorer schema={data.outputSchema} /> | |
| 176 | − ) : ( | |
| 177 | − <p className="py-2 text-sm text-muted-foreground/40">None</p> | |
| 178 | − )} | |
| 179 | − </div> | |
| 180 | − </section> | |
| 167 | + <div className="px-5 py-5 space-y-5"> | |
| 168 | + {data?.inputSchema ? ( | |
| 169 | + <SchemaExplorer schema={data.inputSchema} title="Parameters" /> | |
| 170 | + ) : ( | |
| 171 | + <EmptySection title="Parameters" message="None" /> | |
| 172 | + )} | |
| 173 | + {data?.outputSchema ? ( | |
| 174 | + <SchemaExplorer schema={data.outputSchema} title="Response" /> | |
| 175 | + ) : ( | |
| 176 | + <EmptySection title="Response" message="None" /> | |
| 177 | + )} | |
| 181 | 178 | </div> |
| 182 | 179 | ) : ( |
| 183 | − <div className="px-5 py-5 space-y-6"> | |
| 184 | − <section> | |
| 185 | − <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> | |
| 186 | − Input | |
| 187 | − </h4> | |
| 188 | − <div className="mt-2.5"> | |
| 189 | − {data?.inputTypeScript ? ( | |
| 180 | + <div className="px-5 py-5 space-y-5"> | |
| 181 | + {data?.inputTypeScript ? ( | |
| 182 | + <CardStack> | |
| 183 | + <CardStackHeader>Input</CardStackHeader> | |
| 184 | + <CardStackContent> | |
| 190 | 185 | <ExpandableCodeBlock |
| 191 | 186 | code={data.inputTypeScript} |
| 192 | 187 | definitions={data.definitions} |
| 188 | + className="rounded-none border-0" | |
| 193 | 189 | /> |
| 194 | − ) : ( | |
| 195 | − <p className="py-2 text-sm text-muted-foreground/40">void</p> | |
| 196 | − )} | |
| 197 | − </div> | |
| 198 | − </section> | |
| 199 | − | |
| 200 | − <section> | |
| 201 | − <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> | |
| 202 | − Output | |
| 203 | − </h4> | |
| 204 | − <div className="mt-2.5"> | |
| 205 | − {data?.outputTypeScript ? ( | |
| 190 | + </CardStackContent> | |
| 191 | + </CardStack> | |
| 192 | + ) : ( | |
| 193 | + <EmptySection title="Input" message="void" /> | |
| 194 | + )} | |
| 195 | + {data?.outputTypeScript ? ( | |
| 196 | + <CardStack> | |
| 197 | + <CardStackHeader>Output</CardStackHeader> | |
| 198 | + <CardStackContent> | |
| 206 | 199 | <ExpandableCodeBlock |
| 207 | 200 | code={data.outputTypeScript} |
| 208 | 201 | definitions={data.definitions} |
| 202 | + className="rounded-none border-0" | |
| 209 | 203 | /> |
| 210 | − ) : ( | |
| 211 | − <p className="py-2 text-sm text-muted-foreground/40">void</p> | |
| 212 | − )} | |
| 213 | − </div> | |
| 214 | − </section> | |
| 204 | + </CardStackContent> | |
| 205 | + </CardStack> | |
| 206 | + ) : ( | |
| 207 | + <EmptySection title="Output" message="void" /> | |
| 208 | + )} | |
| 215 | 209 | </div> |
| 216 | 210 | ), |
| 217 | 211 | })} |
| @@ -228,11 +222,11 @@ export function ToolDetailEmpty(props: { hasTools: boolean }) { · 10 unmodified lines | ||
| 228 | 222 | return ( |
| 229 | 223 | <div className="flex h-full items-center justify-center"> |
| 230 | 224 | <div className="text-center"> |
| 231 | − <p className="text-sm font-medium text-foreground/70"> | |
| 225 | + <p className="text-sm font-medium text-foreground"> | |
| 232 | 226 | {props.hasTools ? "Select a tool" : "No tools available"} |
| 233 | 227 | </p> |
| 234 | 228 | {props.hasTools && ( |
| 235 | − <p className="mt-1.5 text-sm text-muted-foreground/50"> | |
| 229 | + <p className="mt-1.5 text-sm text-muted-foreground"> | |
| 236 | 230 | Choose from the list to see what it does. |
| 237 | 231 | </p> |
| 238 | 232 | )} |
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 2 | +import { ChevronRightIcon, SearchIcon, XIcon } from "lucide-react"; | |
| 2 | 3 | import { Button } from "./button"; |
| 3 | 4 | import { Input } from "./input"; |
| 5 | +import { CardStack, CardStackContent } from "./card-stack"; | |
| 6 | +import { cn } from "../lib/utils"; | |
| 4 | 7 | |
| 5 | 8 | // --------------------------------------------------------------------------- |
| 6 | 9 | // Types |
| @@ -15,24 +18,38 @@ export interface ToolSummary { · 8 unmodified lines | ||
| 15 | 18 | |
| 16 | 19 | type TreeNode = { |
| 17 | 20 | segment: string; |
| 21 | + path: string; | |
| 18 | 22 | tool?: ToolSummary; |
| 19 | 23 | children: Map<string, TreeNode>; |
| 20 | 24 | }; |
| 21 | 25 | |
| 26 | +type Row = | |
| 27 | + | { kind: "leaf"; depth: number; path: string; tool: ToolSummary } | |
| 28 | + | { | |
| 29 | + kind: "group"; | |
| 30 | + depth: number; | |
| 31 | + path: string; | |
| 32 | + segment: string; | |
| 33 | + count: number; | |
| 34 | + open: boolean; | |
| 35 | + }; | |
| 36 | + | |
| 22 | 37 | // --------------------------------------------------------------------------- |
| 23 | 38 | // Tree builder |
| 24 | 39 | // --------------------------------------------------------------------------- |
| 25 | 40 | |
| 26 | 41 | const buildTree = (tools: readonly ToolSummary[]): TreeNode => { |
| 27 | − const root: TreeNode = { segment: "", children: new Map() }; | |
| 42 | + const root: TreeNode = { segment: "", path: "", children: new Map() }; | |
| 28 | 43 | |
| 29 | 44 | for (const tool of tools) { |
| 30 | 45 | const parts = tool.name.split("."); |
| 31 | 46 | let node = root; |
| 47 | + let path = ""; | |
| 32 | 48 | for (const part of parts) { |
| 49 | + path = path ? `${path}.${part}` : part; | |
| 33 | 50 | let child = node.children.get(part); |
| 34 | 51 | if (!child) { |
| 35 | − child = { segment: part, children: new Map() }; | |
| 52 | + child = { segment: part, path, children: new Map() }; | |
| 36 | 53 | node.children.set(part, child); |
| 37 | 54 | } |
| 38 | 55 | node = child; |
| @@ -51,6 +68,46 @@ const countLeaves = (node: TreeNode): number => { · 12 unmodified lines | ||
| 51 | 68 | return count; |
| 52 | 69 | }; |
| 53 | 70 | |
| 71 | +const collectGroupPaths = (node: TreeNode, acc: Set<string>): void => { | |
| 72 | + for (const child of node.children.values()) { | |
| 73 | + if (child.children.size > 0) { | |
| 74 | + acc.add(child.path); | |
| 75 | + collectGroupPaths(child, acc); | |
| 76 | + } | |
| 77 | + } | |
| 78 | +}; | |
| 79 | + | |
| 80 | +const flattenTree = ( | |
| 81 | + node: TreeNode, | |
| 82 | + depth: number, | |
| 83 | + openSet: ReadonlySet<string>, | |
| 84 | + acc: Row[], | |
| 85 | +): void => { | |
| 86 | + const sorted = [...node.children.values()].sort((a, b) => a.segment.localeCompare(b.segment)); | |
| 87 | + for (const child of sorted) { | |
| 88 | + const hasChildren = child.children.size > 0; | |
| 89 | + const isLeaf = !!child.tool && !hasChildren; | |
| 90 | + | |
| 91 | + if (isLeaf) { | |
| 92 | + acc.push({ kind: "leaf", depth, path: child.path, tool: child.tool! }); | |
| 93 | + continue; | |
| 94 | + } | |
| 95 | + | |
| 96 | + const open = openSet.has(child.path); | |
| 97 | + acc.push({ | |
| 98 | + kind: "group", | |
| 99 | + depth, | |
| 100 | + path: child.path, | |
| 101 | + segment: child.segment, | |
| 102 | + count: countLeaves(child), | |
| 103 | + open, | |
| 104 | + }); | |
| 105 | + if (open) { | |
| 106 | + flattenTree(child, depth + 1, openSet, acc); | |
| 107 | + } | |
| 108 | + } | |
| 109 | +}; | |
| 110 | + | |
| 54 | 111 | // --------------------------------------------------------------------------- |
| 55 | 112 | // Highlight |
| 56 | 113 | // --------------------------------------------------------------------------- |
| @@ -99,7 +156,7 @@ const highlightMatch = (text: string, search: string) => { · 42 unmodified lines | ||
| 99 | 156 | <> |
| 100 | 157 | {parts.map((p, i) => |
| 101 | 158 | p.hl ? ( |
| 102 | − <mark key={i} className="rounded-sm bg-primary/20 px-px text-foreground"> | |
| 159 | + <mark key={i} className="rounded-sm bg-primary/25 px-px text-foreground"> | |
| 103 | 160 | {p.text} |
| 104 | 161 | </mark> |
| 105 | 162 | ) : ( |
| @@ -110,157 +167,6 @@ const highlightMatch = (text: string, search: string) => { · 4 unmodified lines | ||
| 110 | 167 | ); |
| 111 | 168 | }; |
| 112 | 169 | |
| 113 | −// --------------------------------------------------------------------------- | |
| 114 | −// Tree node view | |
| 115 | −// --------------------------------------------------------------------------- | |
| 116 | − | |
| 117 | −function TreeNodeView(props: { | |
| 118 | − node: TreeNode; | |
| 119 | − depth: number; | |
| 120 | − selectedToolId: string | null; | |
| 121 | − onSelect: (toolId: string) => void; | |
| 122 | − search: string; | |
| 123 | − defaultOpen: boolean; | |
| 124 | −}) { | |
| 125 | − const { node, depth, selectedToolId, onSelect, search, defaultOpen } = props; | |
| 126 | − const hasChildren = node.children.size > 0; | |
| 127 | − const isLeaf = !!node.tool && !hasChildren; | |
| 128 | − | |
| 129 | − const hasSelectedDescendant = useMemo(() => { | |
| 130 | − if (!selectedToolId) return false; | |
| 131 | − const check = (n: TreeNode): boolean => { | |
| 132 | − if (n.tool?.id === selectedToolId) return true; | |
| 133 | − for (const child of n.children.values()) { | |
| 134 | − if (check(child)) return true; | |
| 135 | − } | |
| 136 | − return false; | |
| 137 | − }; | |
| 138 | − return check(node); | |
| 139 | − }, [node, selectedToolId]); | |
| 140 | − | |
| 141 | − const [open, setOpen] = useState(defaultOpen || hasSelectedDescendant); | |
| 142 | − | |
| 143 | − useEffect(() => { | |
| 144 | − if (defaultOpen || hasSelectedDescendant) setOpen(true); | |
| 145 | − }, [defaultOpen, hasSelectedDescendant]); | |
| 146 | − | |
| 147 | − const paddingLeft = 8 + depth * 16; | |
| 148 | − | |
| 149 | − if (isLeaf) { | |
| 150 | − return ( | |
| 151 | − <ToolLeafItem | |
| 152 | − tool={node.tool!} | |
| 153 | − active={node.tool!.id === selectedToolId} | |
| 154 | − onSelect={() => onSelect(node.tool!.id)} | |
| 155 | − search={search} | |
| 156 | − depth={depth} | |
| 157 | − /> | |
| 158 | − ); | |
| 159 | − } | |
| 160 | − | |
| 161 | − const sorted = [...node.children.values()].sort((a, b) => a.segment.localeCompare(b.segment)); | |
| 162 | − const leafCount = countLeaves(node); | |
| 163 | − | |
| 164 | − return ( | |
| 165 | − <div> | |
| 166 | − <Button | |
| 167 | − variant="ghost" | |
| 168 | − onClick={() => setOpen((v) => !v)} | |
| 169 | − aria-expanded={open} | |
| 170 | − className="group flex h-auto w-full items-center gap-1.5 rounded-md py-1 pr-2.5 text-[12px] hover:bg-accent/40 text-left" | |
| 171 | − style={{ paddingLeft }} | |
| 172 | − > | |
| 173 | − <svg | |
| 174 | − viewBox="0 0 8 8" | |
| 175 | − className="size-2 shrink-0 text-muted-foreground/30 transition-transform duration-150" | |
| 176 | − style={{ transform: open ? "rotate(90deg)" : "rotate(0deg)" }} | |
| 177 | − > | |
| 178 | − <path d="M2 1l4 3-4 3z" fill="currentColor" /> | |
| 179 | − </svg> | |
| 180 | − <svg viewBox="0 0 16 16" className="size-3 shrink-0 text-muted-foreground/30"> | |
| 181 | − <path d="M2 4h5l2 2h5v7H2V4z" fill="none" stroke="currentColor" strokeWidth="1.2" /> | |
| 182 | − </svg> | |
| 183 | − <span className="flex-1 truncate font-mono text-foreground/70"> | |
| 184 | − {highlightMatch(node.segment, search)} | |
| 185 | − </span> | |
| 186 | − <span className="shrink-0 tabular-nums text-[10px] text-muted-foreground/25"> | |
| 187 | − {leafCount} | |
| 188 | − </span> | |
| 189 | − </Button> | |
| 190 | − | |
| 191 | − {open && hasChildren && ( | |
| 192 | − <div className="relative flex flex-col gap-px"> | |
| 193 | − <span | |
| 194 | − className="absolute bottom-1 top-0 w-px bg-border/40" | |
| 195 | − style={{ left: paddingLeft + 5 }} | |
| 196 | − aria-hidden | |
| 197 | − /> | |
| 198 | − {sorted.map((child) => ( | |
| 199 | − <TreeNodeView | |
| 200 | − key={child.segment} | |
| 201 | − node={child} | |
| 202 | − depth={depth + 1} | |
| 203 | − selectedToolId={selectedToolId} | |
| 204 | − onSelect={onSelect} | |
| 205 | − search={search} | |
| 206 | − defaultOpen={defaultOpen} | |
| 207 | − /> | |
| 208 | − ))} | |
| 209 | − </div> | |
| 210 | − )} | |
| 211 | − </div> | |
| 212 | − ); | |
| 213 | −} | |
| 214 | − | |
| 215 | −// --------------------------------------------------------------------------- | |
| 216 | −// Leaf item | |
| 217 | −// --------------------------------------------------------------------------- | |
| 218 | − | |
| 219 | −function ToolLeafItem(props: { | |
| 220 | − tool: ToolSummary; | |
| 221 | − active: boolean; | |
| 222 | − onSelect: () => void; | |
| 223 | − search: string; | |
| 224 | − depth: number; | |
| 225 | −}) { | |
| 226 | − const ref = useRef<HTMLButtonElement>(null); | |
| 227 | − const paddingLeft = 8 + props.depth * 16 + 8; | |
| 228 | − const label = props.tool.name.split(".").pop() ?? props.tool.name; | |
| 229 | − | |
| 230 | − useEffect(() => { | |
| 231 | − if (props.active && ref.current) { | |
| 232 | − ref.current.scrollIntoView({ block: "nearest" }); | |
| 233 | − } | |
| 234 | − }, [props.active]); | |
| 235 | − | |
| 236 | − return ( | |
| 237 | − <Button | |
| 238 | − ref={ref} | |
| 239 | − variant="ghost" | |
| 240 | − onClick={props.onSelect} | |
| 241 | − className={[ | |
| 242 | − "group flex h-auto w-full items-center gap-2 rounded-md py-1.5 pr-2.5 text-left", | |
| 243 | − props.active | |
| 244 | − ? "border-l-2 border-l-primary bg-primary/10 text-foreground" | |
| 245 | − : "text-foreground/70 hover:bg-accent/50 hover:text-foreground", | |
| 246 | − ].join(" ")} | |
| 247 | − style={{ paddingLeft }} | |
| 248 | − > | |
| 249 | − <svg viewBox="0 0 16 16" className="size-3 shrink-0 text-muted-foreground/40"> | |
| 250 | − <path | |
| 251 | − d="M4 2h8l1 3H3l1-3zM3 6h10v8H3V6z" | |
| 252 | − fill="none" | |
| 253 | − stroke="currentColor" | |
| 254 | − strokeWidth="1.2" | |
| 255 | − /> | |
| 256 | − </svg> | |
| 257 | − <span className="flex-1 truncate font-mono text-[12px]"> | |
| 258 | − {highlightMatch(label, props.search)} | |
| 259 | − </span> | |
| 260 | − </Button> | |
| 261 | − ); | |
| 262 | −} | |
| 263 | − | |
| 264 | 170 | // --------------------------------------------------------------------------- |
| 265 | 171 | // ToolTree — main export |
| 266 | 172 | // --------------------------------------------------------------------------- |
| @@ -270,20 +176,61 @@ export function ToolTree(props: { · 3 unmodified lines | ||
| 270 | 176 | selectedToolId: string | null; |
| 271 | 177 | onSelect: (toolId: string) => void; |
| 272 | 178 | }) { |
| 179 | + const { tools, selectedToolId, onSelect } = props; | |
| 273 | 180 | const [search, setSearch] = useState(""); |
| 181 | + const [manualOpen, setManualOpen] = useState<Set<string>>(() => new Set()); | |
| 274 | 182 | const searchRef = useRef<HTMLInputElement>(null); |
| 183 | + const selectedRowRef = useRef<HTMLButtonElement>(null); | |
| 184 | + | |
| 275 | 185 | const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); |
| 276 | 186 | |
| 277 | 187 | const filteredTools = useMemo(() => { |
| 278 | − if (terms.length === 0) return props.tools; | |
| 279 | − return props.tools.filter((t) => { | |
| 188 | + if (terms.length === 0) return tools; | |
| 189 | + return tools.filter((t) => { | |
| 280 | 190 | const corpus = [t.name, t.description ?? ""].join(" ").toLowerCase(); |
| 281 | 191 | return terms.every((term) => corpus.includes(term)); |
| 282 | 192 | }); |
| 283 | − }, [props.tools, terms]); | |
| 193 | + }, [tools, terms]); | |
| 284 | 194 | |
| 285 | 195 | const tree = useMemo(() => buildTree(filteredTools), [filteredTools]); |
| 286 | 196 | |
| 197 | + // When searching, expand everything so matches are visible. | |
| 198 | + // Also auto-expand groups that contain the selected tool. | |
| 199 | + const openSet = useMemo(() => { | |
| 200 | + if (terms.length > 0) { | |
| 201 | + const all = new Set<string>(); | |
| 202 | + collectGroupPaths(tree, all); | |
| 203 | + return all; | |
| 204 | + } | |
| 205 | + const set = new Set(manualOpen); | |
| 206 | + if (selectedToolId) { | |
| 207 | + const parts = selectedToolId.split("."); | |
| 208 | + // Progressively add ancestor paths (best-effort, based on dotted name). | |
| 209 | + let acc = ""; | |
| 210 | + for (let i = 0; i < parts.length - 1; i++) { | |
| 211 | + acc = acc ? `${acc}.${parts[i]}` : parts[i]!; | |
| 212 | + set.add(acc); | |
| 213 | + } | |
| 214 | + } | |
| 215 | + return set; | |
| 216 | + }, [tree, manualOpen, selectedToolId, terms.length]); | |
| 217 | + | |
| 218 | + const rows = useMemo(() => { | |
| 219 | + const acc: Row[] = []; | |
| 220 | + flattenTree(tree, 0, openSet, acc); | |
| 221 | + return acc; | |
| 222 | + }, [tree, openSet]); | |
| 223 | + | |
| 224 | + const toggleGroup = (path: string) => { | |
| 225 | + setManualOpen((prev) => { | |
| 226 | + const next = new Set(prev); | |
| 227 | + if (next.has(path)) next.delete(path); | |
| 228 | + else next.add(path); | |
| 229 | + return next; | |
| 230 | + }); | |
| 231 | + }; | |
| 232 | + | |
| 233 | + // Keyboard shortcuts — `/` focuses search, Escape clears | |
| 287 | 234 | useEffect(() => { |
| 288 | 235 | const handler = (e: KeyboardEvent) => { |
| 289 | 236 | if (e.key === "/" && document.activeElement?.tagName !== "INPUT") { |
| @@ -299,68 +246,147 @@ export function ToolTree(props: { · 9 unmodified lines | ||
| 299 | 246 | return () => document.removeEventListener("keydown", handler); |
| 300 | 247 | }, [search]); |
| 301 | 248 | |
| 302 | − const entries = [...tree.children.values()].sort((a, b) => a.segment.localeCompare(b.segment)); | |
| 249 | + // Scroll the selected row into view when it changes | |
| 250 | + useEffect(() => { | |
| 251 | + if (!selectedToolId) return; | |
| 252 | + selectedRowRef.current?.scrollIntoView({ block: "nearest" }); | |
| 253 | + }, [selectedToolId, rows]); | |
| 303 | 254 | |
| 304 | 255 | return ( |
| 305 | − <div className="flex h-full flex-col"> | |
| 306 | − {/* Search */} | |
| 307 | − <div className="shrink-0 border-b border-border px-3 py-2"> | |
| 308 | − <div className="flex h-8 items-center gap-2 rounded-md border border-input bg-background px-2.5"> | |
| 309 | − <svg viewBox="0 0 16 16" className="size-3.5 shrink-0 text-muted-foreground/40"> | |
| 310 | − <circle cx="6.5" cy="6.5" r="5" fill="none" stroke="currentColor" strokeWidth="1.2" /> | |
| 311 | − <path | |
| 312 | − d="M10 10l4.5 4.5" | |
| 313 | − stroke="currentColor" | |
| 314 | − strokeWidth="1.2" | |
| 315 | − strokeLinecap="round" | |
| 316 | − /> | |
| 317 | − </svg> | |
| 256 | + <div className="flex h-full min-h-0 flex-col p-3"> | |
| 257 | + <CardStack className="flex min-h-0 flex-1 flex-col"> | |
| 258 | + {/* Search header */} | |
| 259 | + <div className="flex shrink-0 items-center gap-2 border-b border-border/60 px-3 py-2"> | |
| 260 | + <SearchIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" /> | |
| 318 | 261 | <Input |
| 319 | 262 | ref={searchRef} |
| 320 | 263 | value={search} |
| 321 | 264 | onChange={(e) => setSearch(e.target.value)} |
| 322 | − placeholder={`Filter ${props.tools.length} tools…`} | |
| 323 | − className="min-w-0 flex-1 border-0 bg-transparent p-0 text-[13px] shadow-none outline-none placeholder:text-muted-foreground/40 h-auto rounded-none focus-visible:ring-0 focus-visible:border-transparent" | |
| 265 | + placeholder={`Filter ${tools.length} tools…`} | |
| 266 | + aria-label="Filter tools" | |
| 267 | + className="h-auto min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 text-[13px] shadow-none outline-none placeholder:text-muted-foreground focus-visible:border-transparent focus-visible:ring-0" | |
| 324 | 268 | /> |
| 325 | 269 | {search.length > 0 ? ( |
| 326 | 270 | <Button |
| 327 | 271 | variant="ghost" |
| 328 | 272 | size="icon-xs" |
| 329 | 273 | onClick={() => setSearch("")} |
| 330 | − className="size-5 shrink-0 text-muted-foreground/40 hover:text-foreground" | |
| 274 | + aria-label="Clear search" | |
| 275 | + className="size-5 shrink-0 text-muted-foreground hover:text-foreground" | |
| 331 | 276 | > |
| 332 | − × | |
| 277 | + <XIcon className="size-3.5" /> | |
| 333 | 278 | </Button> |
| 334 | 279 | ) : ( |
| 335 | − <kbd className="shrink-0 rounded border border-border bg-muted px-1 py-px text-[10px] leading-none text-muted-foreground/50"> | |
| 280 | + <kbd className="shrink-0 rounded border border-border bg-muted px-1 py-px text-[10px] leading-none text-muted-foreground"> | |
| 336 | 281 | / |
| 337 | 282 | </kbd> |
| 338 | 283 | )} |
| 339 | 284 | </div> |
| 340 | − </div> | |
| 341 | − | |
| 342 | − {/* Tree */} | |
| 343 | − <div className="flex-1 overflow-y-auto p-1.5"> | |
| 344 | − {filteredTools.length === 0 ? ( | |
| 345 | − <div className="p-4 text-center text-[13px] text-muted-foreground/50"> | |
| 346 | − {terms.length > 0 ? "No tools match your filter" : "No tools available"} | |
| 347 | − </div> | |
| 348 | − ) : ( | |
| 349 | − <div className="flex flex-col gap-px"> | |
| 350 | − {entries.map((node) => ( | |
| 351 | − <TreeNodeView | |
| 352 | − key={node.segment} | |
| 353 | − node={node} | |
| 354 | − depth={0} | |
| 355 | − selectedToolId={props.selectedToolId} | |
| 356 | − onSelect={props.onSelect} | |
| 357 | − search={search} | |
| 358 | − defaultOpen={terms.length > 0} | |
| 359 | − /> | |
| 360 | − ))} | |
| 361 | − </div> | |
| 362 | − )} | |
| 363 | − </div> | |
| 285 | + | |
| 286 | + {/* Tree body */} | |
| 287 | + <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 288 | + {filteredTools.length === 0 ? ( | |
| 289 | + <div className="p-4 text-center text-[13px] text-muted-foreground"> | |
| 290 | + {terms.length > 0 ? "No tools match your filter" : "No tools available"} | |
| 291 | + </div> | |
| 292 | + ) : ( | |
| 293 | + <CardStackContent> | |
| 294 | + {rows.map((row) => | |
| 295 | + row.kind === "leaf" ? ( | |
| 296 | + <ToolLeafRow | |
| 297 | + key={row.path} | |
| 298 | + buttonRef={row.tool.id === selectedToolId ? selectedRowRef : undefined} | |
| 299 | + tool={row.tool} | |
| 300 | + depth={row.depth} | |
| 301 | + active={row.tool.id === selectedToolId} | |
| 302 | + onSelect={() => onSelect(row.tool.id)} | |
| 303 | + search={search} | |
| 304 | + /> | |
| 305 | + ) : ( | |
| 306 | + <ToolGroupRow | |
| 307 | + key={row.path} | |
| 308 | + segment={row.segment} | |
| 309 | + depth={row.depth} | |
| 310 | + count={row.count} | |
| 311 | + open={row.open} | |
| 312 | + onToggle={() => toggleGroup(row.path)} | |
| 313 | + search={search} | |
| 314 | + /> | |
| 315 | + ), | |
| 316 | + )} | |
| 317 | + </CardStackContent> | |
| 318 | + )} | |
| 319 | + </div> | |
| 320 | + </CardStack> | |
| 364 | 321 | </div> |
| 365 | 322 | ); |
| 366 | 323 | } |
| 324 | + | |
| 325 | +// --------------------------------------------------------------------------- | |
| 326 | +// Row renderers | |
| 327 | +// --------------------------------------------------------------------------- | |
| 328 | + | |
| 329 | +const rowIndent = (depth: number) => 12 + depth * 16; | |
| 330 | + | |
| 331 | +const rowBaseClasses = | |
| 332 | + "relative flex h-auto w-full items-center justify-start gap-2 rounded-none py-2 text-[13px] font-normal transition-[background-color] duration-150"; | |
| 333 | + | |
| 334 | +function ToolGroupRow(props: { | |
| 335 | + segment: string; | |
| 336 | + depth: number; | |
| 337 | + count: number; | |
| 338 | + open: boolean; | |
| 339 | + onToggle: () => void; | |
| 340 | + search: string; | |
| 341 | +}) { | |
| 342 | + return ( | |
| 343 | + <Button | |
| 344 | + variant="ghost" | |
| 345 | + aria-expanded={props.open} | |
| 346 | + onClick={props.onToggle} | |
| 347 | + className={cn(rowBaseClasses, "hover:bg-accent/60")} | |
| 348 | + style={{ paddingLeft: rowIndent(props.depth), paddingRight: 12 }} | |
| 349 | + > | |
| 350 | + <ChevronRightIcon | |
| 351 | + aria-hidden | |
| 352 | + className={cn( | |
| 353 | + "size-3.5 shrink-0 text-muted-foreground transition-transform duration-150", | |
| 354 | + props.open && "rotate-90", | |
| 355 | + )} | |
| 356 | + /> | |
| 357 | + <span className="min-w-0 flex-1 truncate text-left font-mono text-[13px] text-foreground"> | |
| 358 | + {highlightMatch(props.segment, props.search)} | |
| 359 | + </span> | |
| 360 | + <span className="shrink-0 tabular-nums text-[11px] text-muted-foreground">{props.count}</span> | |
| 361 | + </Button> | |
| 362 | + ); | |
| 363 | +} | |
| 364 | + | |
| 365 | +function ToolLeafRow(props: { | |
| 366 | + buttonRef?: React.Ref<HTMLButtonElement>; | |
| 367 | + tool: ToolSummary; | |
| 368 | + depth: number; | |
| 369 | + active: boolean; | |
| 370 | + onSelect: () => void; | |
| 371 | + search: string; | |
| 372 | +}) { | |
| 373 | + const label = props.tool.name.split(".").pop() ?? props.tool.name; | |
| 374 | + return ( | |
| 375 | + <Button | |
| 376 | + ref={props.buttonRef} | |
| 377 | + variant="ghost" | |
| 378 | + onClick={props.onSelect} | |
| 379 | + className={cn( | |
| 380 | + rowBaseClasses, | |
| 381 | + props.active | |
| 382 | + ? "bg-primary/15 text-foreground ring-1 ring-inset ring-primary/40 hover:bg-primary/20" | |
| 383 | + : "text-foreground/80 hover:bg-accent/60 hover:text-foreground", | |
| 384 | + )} | |
| 385 | + style={{ paddingLeft: rowIndent(props.depth) + 20, paddingRight: 12 }} | |
| 386 | + > | |
| 387 | + <span className="flex-1 truncate text-left font-mono"> | |
| 388 | + {highlightMatch(label, props.search)} | |
| 389 | + </span> | |
| 390 | + </Button> | |
| 391 | + ); | |
| 392 | +} | |
| @@ -29,6 +29,17 @@ import { · 28 unmodified lines | ||
| 29 | 29 | SelectTrigger, |
| 30 | 30 | SelectValue, |
| 31 | 31 | } from "../components/select"; |
| 32 | +import { | |
| 33 | + CardStack, | |
| 34 | + CardStackContent, | |
| 35 | + CardStackEntry, | |
| 36 | + CardStackEntryActions, | |
| 37 | + CardStackEntryContent, | |
| 38 | + CardStackEntryDescription, | |
| 39 | + CardStackEntryTitle, | |
| 40 | + CardStackHeader, | |
| 41 | +} from "../components/card-stack"; | |
| 42 | +import { Badge } from "../components/badge"; | |
| 32 | 43 | |
| 33 | 44 | // --------------------------------------------------------------------------- |
| 34 | 45 | // Add secret dialog |
| @@ -225,33 +236,25 @@ function SecretRow(props: { · 190 unmodified lines | ||
| 225 | 236 | const { secret } = props; |
| 226 | 237 | |
| 227 | 238 | return ( |
| 228 | − <div className="group relative flex items-center gap-4 rounded-lg border border-border/60 bg-card px-4 py-3 transition-all hover:border-border hover:shadow-[0_1px_3px_rgba(0,0,0,0.04)]"> | |
| 229 | − {/* Content */} | |
| 230 | − <div className="min-w-0 flex-1"> | |
| 231 | − <div className="flex items-center gap-2"> | |
| 232 | − <p className="text-[13px] font-medium text-foreground leading-none">{secret.name}</p> | |
| 233 | − <span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] leading-none text-muted-foreground"> | |
| 234 | − {secret.id} | |
| 235 | − </span> | |
| 236 | − </div> | |
| 239 | + <CardStackEntry> | |
| 240 | + <CardStackEntryContent> | |
| 241 | + <CardStackEntryTitle className="flex items-center gap-2"> | |
| 242 | + <span className="truncate">{secret.name}</span> | |
| 243 | + </CardStackEntryTitle> | |
| 237 | 244 | {secret.purpose && ( |
| 238 | − <p className="mt-1 text-[12px] text-muted-foreground/70 leading-none">{secret.purpose}</p> | |
| 239 | − )} | |
| 240 | − </div> | |
| 241 | − | |
| 242 | − {/* Provider + actions */} | |
| 243 | − <div className="flex items-center gap-1.5"> | |
| 244 | − {secret.provider && ( | |
| 245 | − <span className="rounded-full border border-border/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground leading-none"> | |
| 246 | − {secret.provider} | |
| 247 | − </span> | |
| 245 | + <CardStackEntryDescription> | |
| 246 | + <div className="flex gap-2 flex-col text-xs">{secret.purpose}</div> | |
| 247 | + </CardStackEntryDescription> | |
| 248 | 248 | )} |
| 249 | + </CardStackEntryContent> | |
| 250 | + <CardStackEntryActions> | |
| 251 | + {secret.provider && <Badge variant="outline">{secret.provider}</Badge>} | |
| 249 | 252 | <DropdownMenu> |
| 250 | 253 | <DropdownMenuTrigger asChild> |
| 251 | 254 | <Button |
| 252 | 255 | variant="ghost" |
| 253 | 256 | size="icon" |
| 254 | − className="size-7 opacity-0 group-hover:opacity-100 transition-opacity" | |
| 257 | + className="size-7 opacity-0 transition-opacity group-hover/card-stack-entry:opacity-100 group-focus-within/card-stack-entry:opacity-100 data-[state=open]:opacity-100" | |
| 255 | 258 | > |
| 256 | 259 | <svg viewBox="0 0 16 16" className="size-3"> |
| 257 | 260 | <circle cx="8" cy="3" r="1.2" fill="currentColor" /> |
| @@ -269,8 +272,8 @@ function SecretRow(props: { · 11 unmodified lines | ||
| 269 | 272 | </DropdownMenuItem> |
| 270 | 273 | </DropdownMenuContent> |
| 271 | 274 | </DropdownMenu> |
| 272 | − </div> | |
| 273 | − </div> | |
| 275 | + </CardStackEntryActions> | |
| 276 | + </CardStackEntry> | |
| 274 | 277 | ); |
| 275 | 278 | } |
| 276 | 279 | |
| @@ -321,26 +324,23 @@ export function SecretsPage(props: { secretProviderPlugins: readonly SecretProvi · 44 unmodified lines | ||
| 321 | 324 | {/* Provider plugins */} |
| 322 | 325 | {secretProviderPlugins.length > 0 && ( |
| 323 | 326 | <div className="mb-10"> |
| 324 | − <div className="flex items-center gap-3 mb-4"> | |
| 325 | − <h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/60"> | |
| 326 | − Providers | |
| 327 | − </h2> | |
| 328 | − <div className="flex-1 h-px bg-border/50" /> | |
| 329 | − </div> | |
| 330 | − <div className="grid gap-3"> | |
| 331 | − {secretProviderPlugins.map((plugin) => ( | |
| 332 | − <Suspense | |
| 333 | − key={plugin.key} | |
| 334 | − fallback={ | |
| 335 | − <div className="rounded-xl border border-border/60 bg-card p-5 animate-pulse"> | |
| 336 | − <div className="h-4 w-24 rounded bg-muted" /> | |
| 337 | − </div> | |
| 338 | − } | |
| 339 | − > | |
| 340 | − <plugin.settings /> | |
| 341 | − </Suspense> | |
| 342 | − ))} | |
| 343 | − </div> | |
| 327 | + <CardStack> | |
| 328 | + <CardStackHeader>Providers</CardStackHeader> | |
| 329 | + <CardStackContent> | |
| 330 | + {secretProviderPlugins.map((plugin) => ( | |
| 331 | + <Suspense | |
| 332 | + key={plugin.key} | |
| 333 | + fallback={ | |
| 334 | + <div className="px-4 py-3 animate-pulse"> | |
| 335 | + <div className="h-4 w-24 rounded bg-muted" /> | |
| 336 | + </div> | |
| 337 | + } | |
| 338 | + > | |
| 339 | + <plugin.settings /> | |
| 340 | + </Suspense> | |
| 341 | + ))} | |
| 342 | + </CardStackContent> | |
| 343 | + </CardStack> | |
| 344 | 344 | </div> |
| 345 | 345 | )} |
| 346 | 346 | |
| @@ -357,33 +357,45 @@ export function SecretsPage(props: { secretProviderPlugins: readonly SecretProvi · 10 unmodified lines | ||
| 357 | 357 | <p className="text-[13px] text-destructive">Failed to load secrets</p> |
| 358 | 358 | </div> |
| 359 | 359 | ), |
| 360 | − onSuccess: ({ value }) => | |
| 361 | − value.length === 0 ? ( | |
| 362 | − <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/70 py-16"> | |
| 363 | − <p className="text-[13px] font-medium text-foreground/60 mb-1">No secrets yet</p> | |
| 364 | − <p className="text-[12px] text-muted-foreground/50 mb-5 max-w-[240px] text-center leading-relaxed"> | |
| 365 | − Add API keys and credentials to authenticate your sources. | |
| 366 | − </p> | |
| 367 | − <Button variant="outline" size="sm" onClick={() => setAddOpen(true)}> | |
| 368 | − Add your first secret | |
| 369 | − </Button> | |
| 370 | − </div> | |
| 371 | − ) : ( | |
| 372 | − <div className="grid gap-1.5"> | |
| 373 | − {value.map((s) => ( | |
| 374 | − <SecretRow | |
| 375 | − key={s.id} | |
| 376 | − secret={{ | |
| 377 | − id: s.id, | |
| 378 | − name: s.name, | |
| 379 | − purpose: s.purpose, | |
| 380 | − provider: s.provider ? String(s.provider) : undefined, | |
| 381 | − }} | |
| 382 | − onRemove={() => handleRemove(s.id)} | |
| 383 | − /> | |
| 384 | − ))} | |
| 385 | − </div> | |
| 386 | − ), | |
| 360 | + onSuccess: ({ value }) => ( | |
| 361 | + <CardStack> | |
| 362 | + <CardStackHeader>Secrets</CardStackHeader> | |
| 363 | + <CardStackContent> | |
| 364 | + {value.length === 0 ? ( | |
| 365 | + <CardStackEntry> | |
| 366 | + <CardStackEntryContent> | |
| 367 | + <CardStackEntryDescription> | |
| 368 | + Add API keys and credentials to authenticate your sources. | |
| 369 | + </CardStackEntryDescription> | |
| 370 | + </CardStackEntryContent> | |
| 371 | + <CardStackEntryActions> | |
| 372 | + <Button | |
| 373 | + variant="link" | |
| 374 | + size="sm" | |
| 375 | + className="h-7 px-0 text-[12px]" | |
| 376 | + onClick={() => setAddOpen(true)} | |
| 377 | + > | |
| 378 | + Add your first secret | |
| 379 | + </Button> | |
| 380 | + </CardStackEntryActions> | |
| 381 | + </CardStackEntry> | |
| 382 | + ) : ( | |
| 383 | + value.map((s) => ( | |
| 384 | + <SecretRow | |
| 385 | + key={s.id} | |
| 386 | + secret={{ | |
| 387 | + id: s.id, | |
| 388 | + name: s.name, | |
| 389 | + purpose: s.purpose, | |
| 390 | + provider: s.provider ? String(s.provider) : undefined, | |
| 391 | + }} | |
| 392 | + onRemove={() => handleRemove(s.id)} | |
| 393 | + /> | |
| 394 | + )) | |
| 395 | + )} | |
| 396 | + </CardStackContent> | |
| 397 | + </CardStack> | |
| 398 | + ), | |
| 387 | 399 | })} |
| 388 | 400 | |
| 389 | 401 | <AddSecretDialog open={addOpen} onOpenChange={setAddOpen} /> |