* 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 | import { useState, useCallback } from "react"; | 1 | import { useState, useCallback } from "react"; |
| 2 | import { ChevronRight } from "lucide-react"; | 2 | import { ChevronRight } from "lucide-react"; |
| 3 | import { CardStack, CardStackHeader, CardStackContent } from "./card-stack"; | ||
| 3 | 4 | ||
| 4 | // --------------------------------------------------------------------------- | 5 | // --------------------------------------------------------------------------- |
| 5 | // JSON Schema types (subset we render) | 6 | // JSON Schema types (subset we render) |
| @@ -180,7 +181,7 @@ const mergeAllOf = (schemas: JsonSchema[], root: JsonSchema): JsonSchema => { · 174 unmodified lines | |||
| 180 | // Type label styling — plain text, no colored pills | 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 | // PropertyRow | 187 | // PropertyRow |
| @@ -231,20 +232,20 @@ function PropertyRow(props: { · 44 unmodified lines | |||
| 231 | : undefined | 232 | : undefined |
| 232 | } | 233 | } |
| 233 | className={[ | 234 | className={[ |
| 234 | "flex items-start gap-2 py-2.5 px-3", | 235 | "flex items-start gap-2 py-2.5 px-4", |
| 235 | expandable ? "cursor-pointer hover:bg-accent/30 transition-colors" : "", | 236 | expandable ? "cursor-pointer hover:bg-accent/40 transition-colors" : "", |
| 236 | ].join(" ")} | 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 | {/* Chevron or dot */} | 240 | {/* Chevron or dot */} |
| 240 | <div className="mt-0.5 flex size-4 shrink-0 items-center justify-center"> | 241 | <div className="mt-0.5 flex size-4 shrink-0 items-center justify-center"> |
| 241 | {expandable ? ( | 242 | {expandable ? ( |
| 242 | <ChevronRight | 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 | style={open ? { transform: "rotate(90deg)" } : undefined} | 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 | </div> | 250 | </div> |
| 250 | 251 | ||
| @@ -254,14 +255,14 @@ function PropertyRow(props: { · 3 unmodified lines | |||
| 254 | <p className={typeClasses}>{typeLabel}</p> | 255 | <p className={typeClasses}>{typeLabel}</p> |
| 255 | {!hideRequiredBadge && | 256 | {!hideRequiredBadge && |
| 256 | (required ? ( | 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 | required | 259 | required |
| 259 | </p> | 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 | {schema.default !== undefined && ( | 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 | = {JSON.stringify(schema.default)} | 266 | = {JSON.stringify(schema.default)} |
| 266 | </p> | 267 | </p> |
| 267 | )} | 268 | )} |
| @@ -271,8 +272,8 @@ function PropertyRow(props: { · 3 unmodified lines | |||
| 271 | {/* Description — below the row */} | 272 | {/* Description — below the row */} |
| 272 | {description && ( | 273 | {description && ( |
| 273 | <p | 274 | <p |
| 274 | className="px-3 pb-2 text-[0.8125rem] leading-5 text-muted-foreground/50" | 275 | className="pr-4 pb-2.5 text-[0.8125rem] leading-5 text-muted-foreground" |
| 275 | style={{ paddingLeft: `${depth * 16 + 32}px` }} | 276 | style={{ paddingLeft: `${depth * 16 + 36}px` }} |
| 276 | > | 277 | > |
| 277 | {description} | 278 | {description} |
| 278 | </p> | 279 | </p> |
| @@ -280,7 +281,7 @@ function PropertyRow(props: { · 1 unmodified line | |||
| 280 | 281 | ||
| 281 | {/* Children — rendered lazily on expand */} | 282 | {/* Children — rendered lazily on expand */} |
| 282 | {open && expandable && ( | 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 | <PropertyChildren schema={childSchema} root={root} depth={depth + 1} /> | 285 | <PropertyChildren schema={childSchema} root={root} depth={depth + 1} /> |
| 285 | </div> | 286 | </div> |
| 286 | )} | 287 | )} |
| @@ -297,7 +298,7 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: · 10 unmodified lines | |||
| 297 | 298 | ||
| 298 | if (depth > 6) { | 299 | if (depth > 6) { |
| 299 | return ( | 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 | Nested too deep to display. | 302 | Nested too deep to display. |
| 302 | </p> | 303 | </p> |
| 303 | ); | 304 | ); |
| @@ -316,7 +317,7 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: · 12 unmodified lines | |||
| 316 | return a.localeCompare(b); | 317 | return a.localeCompare(b); |
| 317 | }); | 318 | }); |
| 318 | return ( | 319 | return ( |
| 319 | <div className="divide-y divide-border/20"> | 320 | <div className="divide-y divide-border/50"> |
| 320 | {entries.map(([key, value], i) => ( | 321 | {entries.map(([key, value], i) => ( |
| 321 | <PropertyRow | 322 | <PropertyRow |
| 322 | key={key} | 323 | key={key} |
| @@ -356,12 +357,12 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: · 33 unmodified lines | |||
| 356 | return ( | 357 | return ( |
| 357 | <div> | 358 | <div> |
| 358 | <p | 359 | <p |
| 359 | className="px-3 py-2 text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/30" | 360 | className="px-4 py-2 text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground" |
| 360 | style={depth > 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined} | 361 | style={depth > 0 ? { paddingLeft: `${depth * 16 + 16}px` } : undefined} |
| 361 | > | 362 | > |
| 362 | {label} | 363 | {label} |
| 363 | </p> | 364 | </p> |
| 364 | <div className="divide-y divide-border/20"> | 365 | <div className="divide-y divide-border/50"> |
| 365 | {variants.map((variant, i) => ( | 366 | {variants.map((variant, i) => ( |
| 366 | <PropertyRow | 367 | <PropertyRow |
| 367 | key={i} | 368 | key={i} |
| @@ -394,30 +395,69 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: · 26 unmodified lines | |||
| 394 | return null; | 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 | // SchemaExplorer — main export | 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 | const schema = props.schema as JsonSchema | undefined; | 419 | const schema = props.schema as JsonSchema | undefined; |
| 403 | if (!schema) return null; | 420 | if (!schema) return null; |
| 404 | 421 | ||
| 405 | const hasContent = isExpandable(schema, schema); | 422 | const hasContent = isExpandable(schema, schema); |
| 423 | const title = props.title; | ||
| 406 | 424 | ||
| 407 | if (!hasContent) { | 425 | if (!hasContent) { |
| 408 | const typeLabel = getTypeLabel(schema, schema); | 426 | const typeLabel = getTypeLabel(schema, schema); |
| 409 | return ( | 427 | return ( |
| 410 | <div className="rounded-lg border border-border/40 px-4 py-3"> | 428 | <CardStack> |
| 411 | <p className="text-sm text-muted-foreground/50"> | 429 | {title && <CardStackHeader>{title}</CardStackHeader>} |
| 412 | <span className={typeClasses}>{typeLabel}</span> | 430 | <CardStackContent> |
| 413 | </p> | 431 | <div className="flex items-center gap-2 px-4 py-3"> |
| 414 | </div> | 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 | return ( | 443 | return ( |
| 419 | <div className="rounded-lg border border-border/40 overflow-hidden divide-y divide-border/20"> | 444 | <CardStack> |
| 420 | <PropertyChildren schema={schema} root={schema} depth={0} /> | 445 | {title && ( |
| 421 | </div> | 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 | import { Markdown } from "./markdown"; | 6 | import { Markdown } from "./markdown"; |
| 7 | import { SchemaExplorer } from "./schema-explorer"; | 7 | import { SchemaExplorer } from "./schema-explorer"; |
| 8 | import { ExpandableCodeBlock } from "./expandable-code-block"; | 8 | import { ExpandableCodeBlock } from "./expandable-code-block"; |
| 9 | import { CardStack, CardStackHeader, CardStackContent } from "./card-stack"; | ||
| 9 | import { Copy, Check, ChevronRight } from "lucide-react"; | 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 | // Copy button | 24 | // Copy button |
| 13 | // --------------------------------------------------------------------------- | 25 | // --------------------------------------------------------------------------- |
| @@ -92,7 +104,7 @@ export function ToolDetail(props: { · 78 unmodified lines | |||
| 92 | <div className="shrink-0 border-b border-border/40"> | 104 | <div className="shrink-0 border-b border-border/40"> |
| 93 | <div className="px-5 pt-4 pb-0"> | 105 | <div className="px-5 pt-4 pb-0"> |
| 94 | {crumbs.length > 1 && ( | 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 | {crumbs.slice(0, -1).map((part, i) => ( | 108 | {crumbs.slice(0, -1).map((part, i) => ( |
| 97 | <span key={i} className="flex items-center gap-1"> | 109 | <span key={i} className="flex items-center gap-1"> |
| 98 | {i > 0 && <ChevronRight className="size-3 shrink-0" />} | 110 | {i > 0 && <ChevronRight className="size-3 shrink-0" />} |
| @@ -106,7 +118,7 @@ export function ToolDetail(props: { · 7 unmodified lines | |||
| 106 | <CopyButton text={props.toolId} label="Copy tool ID" /> | 118 | <CopyButton text={props.toolId} label="Copy tool ID" /> |
| 107 | </div> | 119 | </div> |
| 108 | {props.toolDescription && ( | 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 | <Markdown>{props.toolDescription}</Markdown> | 122 | <Markdown>{props.toolDescription}</Markdown> |
| 111 | </div> | 123 | </div> |
| 112 | )} | 124 | )} |
| @@ -122,7 +134,7 @@ export function ToolDetail(props: { · 9 unmodified lines | |||
| 122 | "border-b-2 pb-2.5 text-sm font-medium transition-colors rounded-none", | 134 | "border-b-2 pb-2.5 text-sm font-medium transition-colors rounded-none", |
| 123 | tab === "schema" | 135 | tab === "schema" |
| 124 | ? "border-primary text-foreground" | 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 | ].join(" ")} | 138 | ].join(" ")} |
| 127 | > | 139 | > |
| 128 | Schema | 140 | Schema |
| @@ -136,7 +148,7 @@ export function ToolDetail(props: { · 7 unmodified lines | |||
| 136 | "border-b-2 pb-2.5 text-sm font-medium transition-colors rounded-none", | 148 | "border-b-2 pb-2.5 text-sm font-medium transition-colors rounded-none", |
| 137 | tab === "typescript" | 149 | tab === "typescript" |
| 138 | ? "border-primary text-foreground" | 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 | ].join(" ")} | 152 | ].join(" ")} |
| 141 | > | 153 | > |
| 142 | TypeScript | 154 | TypeScript |
| @@ -152,66 +164,48 @@ export function ToolDetail(props: { · 9 unmodified lines | |||
| 152 | onFailure: () => <div className="p-5 text-sm text-destructive">Something went wrong</div>, | 164 | onFailure: () => <div className="p-5 text-sm text-destructive">Something went wrong</div>, |
| 153 | onSuccess: () => | 165 | onSuccess: () => |
| 154 | tab === "schema" ? ( | 166 | tab === "schema" ? ( |
| 155 | <div className="px-5 py-5 space-y-6"> | 167 | <div className="px-5 py-5 space-y-5"> |
| 156 | <section> | 168 | {data?.inputSchema ? ( |
| 157 | <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> | 169 | <SchemaExplorer schema={data.inputSchema} title="Parameters" /> |
| 158 | Parameters | 170 | ) : ( |
| 159 | </h4> | 171 | <EmptySection title="Parameters" message="None" /> |
| 160 | <div className="mt-2.5"> | 172 | )} |
| 161 | {data?.inputSchema ? ( | 173 | {data?.outputSchema ? ( |
| 162 | <SchemaExplorer schema={data.inputSchema} /> | 174 | <SchemaExplorer schema={data.outputSchema} title="Response" /> |
| 163 | ) : ( | 175 | ) : ( |
| 164 | <p className="py-2 text-sm text-muted-foreground/40">None</p> | 176 | <EmptySection title="Response" message="None" /> |
| 165 | )} | 177 | )} |
| 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> | ||
| 181 | </div> | 178 | </div> |
| 182 | ) : ( | 179 | ) : ( |
| 183 | <div className="px-5 py-5 space-y-6"> | 180 | <div className="px-5 py-5 space-y-5"> |
| 184 | <section> | 181 | {data?.inputTypeScript ? ( |
| 185 | <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> | 182 | <CardStack> |
| 186 | Input | 183 | <CardStackHeader>Input</CardStackHeader> |
| 187 | </h4> | 184 | <CardStackContent> |
| 188 | <div className="mt-2.5"> | ||
| 189 | {data?.inputTypeScript ? ( | ||
| 190 | <ExpandableCodeBlock | 185 | <ExpandableCodeBlock |
| 191 | code={data.inputTypeScript} | 186 | code={data.inputTypeScript} |
| 192 | definitions={data.definitions} | 187 | definitions={data.definitions} |
| 188 | className="rounded-none border-0" | ||
| 193 | /> | 189 | /> |
| 194 | ) : ( | 190 | </CardStackContent> |
| 195 | <p className="py-2 text-sm text-muted-foreground/40">void</p> | 191 | </CardStack> |
| 196 | )} | 192 | ) : ( |
| 197 | </div> | 193 | <EmptySection title="Input" message="void" /> |
| 198 | </section> | 194 | )} |
| 199 | 195 | {data?.outputTypeScript ? ( | |
| 200 | <section> | 196 | <CardStack> |
| 201 | <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> | 197 | <CardStackHeader>Output</CardStackHeader> |
| 202 | Output | 198 | <CardStackContent> |
| 203 | </h4> | ||
| 204 | <div className="mt-2.5"> | ||
| 205 | {data?.outputTypeScript ? ( | ||
| 206 | <ExpandableCodeBlock | 199 | <ExpandableCodeBlock |
| 207 | code={data.outputTypeScript} | 200 | code={data.outputTypeScript} |
| 208 | definitions={data.definitions} | 201 | definitions={data.definitions} |
| 202 | className="rounded-none border-0" | ||
| 209 | /> | 203 | /> |
| 210 | ) : ( | 204 | </CardStackContent> |
| 211 | <p className="py-2 text-sm text-muted-foreground/40">void</p> | 205 | </CardStack> |
| 212 | )} | 206 | ) : ( |
| 213 | </div> | 207 | <EmptySection title="Output" message="void" /> |
| 214 | </section> | 208 | )} |
| 215 | </div> | 209 | </div> |
| 216 | ), | 210 | ), |
| 217 | })} | 211 | })} |
| @@ -228,11 +222,11 @@ export function ToolDetailEmpty(props: { hasTools: boolean }) { · 10 unmodified lines | |||
| 228 | return ( | 222 | return ( |
| 229 | <div className="flex h-full items-center justify-center"> | 223 | <div className="flex h-full items-center justify-center"> |
| 230 | <div className="text-center"> | 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 | {props.hasTools ? "Select a tool" : "No tools available"} | 226 | {props.hasTools ? "Select a tool" : "No tools available"} |
| 233 | </p> | 227 | </p> |
| 234 | {props.hasTools && ( | 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 | Choose from the list to see what it does. | 230 | Choose from the list to see what it does. |
| 237 | </p> | 231 | </p> |
| 238 | )} | 232 | )} |
| @@ -1,6 +1,9 @@ | |||
| 1 | import { useEffect, useMemo, useRef, useState } from "react"; | 1 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 2 | import { ChevronRightIcon, SearchIcon, XIcon } from "lucide-react"; | ||
| 2 | import { Button } from "./button"; | 3 | import { Button } from "./button"; |
| 3 | import { Input } from "./input"; | 4 | import { Input } from "./input"; |
| 5 | import { CardStack, CardStackContent } from "./card-stack"; | ||
| 6 | import { cn } from "../lib/utils"; | ||
| 4 | 7 | ||
| 5 | // --------------------------------------------------------------------------- | 8 | // --------------------------------------------------------------------------- |
| 6 | // Types | 9 | // Types |
| @@ -15,24 +18,38 @@ export interface ToolSummary { · 8 unmodified lines | |||
| 15 | 18 | ||
| 16 | type TreeNode = { | 19 | type TreeNode = { |
| 17 | segment: string; | 20 | segment: string; |
| 21 | path: string; | ||
| 18 | tool?: ToolSummary; | 22 | tool?: ToolSummary; |
| 19 | children: Map<string, TreeNode>; | 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 | // Tree builder | 38 | // Tree builder |
| 24 | // --------------------------------------------------------------------------- | 39 | // --------------------------------------------------------------------------- |
| 25 | 40 | ||
| 26 | const buildTree = (tools: readonly ToolSummary[]): TreeNode => { | 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 | for (const tool of tools) { | 44 | for (const tool of tools) { |
| 30 | const parts = tool.name.split("."); | 45 | const parts = tool.name.split("."); |
| 31 | let node = root; | 46 | let node = root; |
| 47 | let path = ""; | ||
| 32 | for (const part of parts) { | 48 | for (const part of parts) { |
| 49 | path = path ? `${path}.${part}` : part; | ||
| 33 | let child = node.children.get(part); | 50 | let child = node.children.get(part); |
| 34 | if (!child) { | 51 | if (!child) { |
| 35 | child = { segment: part, children: new Map() }; | 52 | child = { segment: part, path, children: new Map() }; |
| 36 | node.children.set(part, child); | 53 | node.children.set(part, child); |
| 37 | } | 54 | } |
| 38 | node = child; | 55 | node = child; |
| @@ -51,6 +68,46 @@ const countLeaves = (node: TreeNode): number => { · 12 unmodified lines | |||
| 51 | return count; | 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 | // Highlight | 112 | // Highlight |
| 56 | // --------------------------------------------------------------------------- | 113 | // --------------------------------------------------------------------------- |
| @@ -99,7 +156,7 @@ const highlightMatch = (text: string, search: string) => { · 42 unmodified lines | |||
| 99 | <> | 156 | <> |
| 100 | {parts.map((p, i) => | 157 | {parts.map((p, i) => |
| 101 | p.hl ? ( | 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 | {p.text} | 160 | {p.text} |
| 104 | </mark> | 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 | // ToolTree — main export | 171 | // ToolTree — main export |
| 266 | // --------------------------------------------------------------------------- | 172 | // --------------------------------------------------------------------------- |
| @@ -270,20 +176,61 @@ export function ToolTree(props: { · 3 unmodified lines | |||
| 270 | selectedToolId: string | null; | 176 | selectedToolId: string | null; |
| 271 | onSelect: (toolId: string) => void; | 177 | onSelect: (toolId: string) => void; |
| 272 | }) { | 178 | }) { |
| 179 | const { tools, selectedToolId, onSelect } = props; | ||
| 273 | const [search, setSearch] = useState(""); | 180 | const [search, setSearch] = useState(""); |
| 181 | const [manualOpen, setManualOpen] = useState<Set<string>>(() => new Set()); | ||
| 274 | const searchRef = useRef<HTMLInputElement>(null); | 182 | const searchRef = useRef<HTMLInputElement>(null); |
| 183 | const selectedRowRef = useRef<HTMLButtonElement>(null); | ||
| 184 | |||
| 275 | const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); | 185 | const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); |
| 276 | 186 | ||
| 277 | const filteredTools = useMemo(() => { | 187 | const filteredTools = useMemo(() => { |
| 278 | if (terms.length === 0) return props.tools; | 188 | if (terms.length === 0) return tools; |
| 279 | return props.tools.filter((t) => { | 189 | return tools.filter((t) => { |
| 280 | const corpus = [t.name, t.description ?? ""].join(" ").toLowerCase(); | 190 | const corpus = [t.name, t.description ?? ""].join(" ").toLowerCase(); |
| 281 | return terms.every((term) => corpus.includes(term)); | 191 | return terms.every((term) => corpus.includes(term)); |
| 282 | }); | 192 | }); |
| 283 | }, [props.tools, terms]); | 193 | }, [tools, terms]); |
| 284 | 194 | ||
| 285 | const tree = useMemo(() => buildTree(filteredTools), [filteredTools]); | 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 | useEffect(() => { | 234 | useEffect(() => { |
| 288 | const handler = (e: KeyboardEvent) => { | 235 | const handler = (e: KeyboardEvent) => { |
| 289 | if (e.key === "/" && document.activeElement?.tagName !== "INPUT") { | 236 | if (e.key === "/" && document.activeElement?.tagName !== "INPUT") { |
| @@ -299,68 +246,147 @@ export function ToolTree(props: { · 9 unmodified lines | |||
| 299 | return () => document.removeEventListener("keydown", handler); | 246 | return () => document.removeEventListener("keydown", handler); |
| 300 | }, [search]); | 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 | return ( | 255 | return ( |
| 305 | <div className="flex h-full flex-col"> | 256 | <div className="flex h-full min-h-0 flex-col p-3"> |
| 306 | {/* Search */} | 257 | <CardStack className="flex min-h-0 flex-1 flex-col"> |
| 307 | <div className="shrink-0 border-b border-border px-3 py-2"> | 258 | {/* Search header */} |
| 308 | <div className="flex h-8 items-center gap-2 rounded-md border border-input bg-background px-2.5"> | 259 | <div className="flex shrink-0 items-center gap-2 border-b border-border/60 px-3 py-2"> |
| 309 | <svg viewBox="0 0 16 16" className="size-3.5 shrink-0 text-muted-foreground/40"> | 260 | <SearchIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" /> |
| 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> | ||
| 318 | <Input | 261 | <Input |
| 319 | ref={searchRef} | 262 | ref={searchRef} |
| 320 | value={search} | 263 | value={search} |
| 321 | onChange={(e) => setSearch(e.target.value)} | 264 | onChange={(e) => setSearch(e.target.value)} |
| 322 | placeholder={`Filter ${props.tools.length} tools…`} | 265 | placeholder={`Filter ${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" | 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 | {search.length > 0 ? ( | 269 | {search.length > 0 ? ( |
| 326 | <Button | 270 | <Button |
| 327 | variant="ghost" | 271 | variant="ghost" |
| 328 | size="icon-xs" | 272 | size="icon-xs" |
| 329 | onClick={() => setSearch("")} | 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 | </Button> | 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 | </kbd> | 282 | </kbd> |
| 338 | )} | 283 | )} |
| 339 | </div> | 284 | </div> |
| 340 | </div> | 285 | |
| 341 | 286 | {/* Tree body */} | |
| 342 | {/* Tree */} | 287 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 343 | <div className="flex-1 overflow-y-auto p-1.5"> | 288 | {filteredTools.length === 0 ? ( |
| 344 | {filteredTools.length === 0 ? ( | 289 | <div className="p-4 text-center text-[13px] text-muted-foreground"> |
| 345 | <div className="p-4 text-center text-[13px] text-muted-foreground/50"> | 290 | {terms.length > 0 ? "No tools match your filter" : "No tools available"} |
| 346 | {terms.length > 0 ? "No tools match your filter" : "No tools available"} | 291 | </div> |
| 347 | </div> | 292 | ) : ( |
| 348 | ) : ( | 293 | <CardStackContent> |
| 349 | <div className="flex flex-col gap-px"> | 294 | {rows.map((row) => |
| 350 | {entries.map((node) => ( | 295 | row.kind === "leaf" ? ( |
| 351 | <TreeNodeView | 296 | <ToolLeafRow |
| 352 | key={node.segment} | 297 | key={row.path} |
| 353 | node={node} | 298 | buttonRef={row.tool.id === selectedToolId ? selectedRowRef : undefined} |
| 354 | depth={0} | 299 | tool={row.tool} |
| 355 | selectedToolId={props.selectedToolId} | 300 | depth={row.depth} |
| 356 | onSelect={props.onSelect} | 301 | active={row.tool.id === selectedToolId} |
| 357 | search={search} | 302 | onSelect={() => onSelect(row.tool.id)} |
| 358 | defaultOpen={terms.length > 0} | 303 | search={search} |
| 359 | /> | 304 | /> |
| 360 | ))} | 305 | ) : ( |
| 361 | </div> | 306 | <ToolGroupRow |
| 362 | )} | 307 | key={row.path} |
| 363 | </div> | 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 | </div> | 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 | SelectTrigger, | 29 | SelectTrigger, |
| 30 | SelectValue, | 30 | SelectValue, |
| 31 | } from "../components/select"; | 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 | // Add secret dialog | 45 | // Add secret dialog |
| @@ -225,33 +236,25 @@ function SecretRow(props: { · 190 unmodified lines | |||
| 225 | const { secret } = props; | 236 | const { secret } = props; |
| 226 | 237 | ||
| 227 | return ( | 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)]"> | 239 | <CardStackEntry> |
| 229 | {/* Content */} | 240 | <CardStackEntryContent> |
| 230 | <div className="min-w-0 flex-1"> | 241 | <CardStackEntryTitle className="flex items-center gap-2"> |
| 231 | <div className="flex items-center gap-2"> | 242 | <span className="truncate">{secret.name}</span> |
| 232 | <p className="text-[13px] font-medium text-foreground leading-none">{secret.name}</p> | 243 | </CardStackEntryTitle> |
| 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> | ||
| 237 | {secret.purpose && ( | 244 | {secret.purpose && ( |
| 238 | <p className="mt-1 text-[12px] text-muted-foreground/70 leading-none">{secret.purpose}</p> | 245 | <CardStackEntryDescription> |
| 239 | )} | 246 | <div className="flex gap-2 flex-col text-xs">{secret.purpose}</div> |
| 240 | </div> | 247 | </CardStackEntryDescription> |
| 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> | ||
| 248 | )} | 248 | )} |
| 249 | </CardStackEntryContent> | ||
| 250 | <CardStackEntryActions> | ||
| 251 | {secret.provider && <Badge variant="outline">{secret.provider}</Badge>} | ||
| 249 | <DropdownMenu> | 252 | <DropdownMenu> |
| 250 | <DropdownMenuTrigger asChild> | 253 | <DropdownMenuTrigger asChild> |
| 251 | <Button | 254 | <Button |
| 252 | variant="ghost" | 255 | variant="ghost" |
| 253 | size="icon" | 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 | <svg viewBox="0 0 16 16" className="size-3"> | 259 | <svg viewBox="0 0 16 16" className="size-3"> |
| 257 | <circle cx="8" cy="3" r="1.2" fill="currentColor" /> | 260 | <circle cx="8" cy="3" r="1.2" fill="currentColor" /> |
| @@ -269,8 +272,8 @@ function SecretRow(props: { · 11 unmodified lines | |||
| 269 | </DropdownMenuItem> | 272 | </DropdownMenuItem> |
| 270 | </DropdownMenuContent> | 273 | </DropdownMenuContent> |
| 271 | </DropdownMenu> | 274 | </DropdownMenu> |
| 272 | </div> | 275 | </CardStackEntryActions> |
| 273 | </div> | 276 | </CardStackEntry> |
| 274 | ); | 277 | ); |
| 275 | } | 278 | } |
| 276 | 279 | ||
| @@ -321,26 +324,23 @@ export function SecretsPage(props: { secretProviderPlugins: readonly SecretProvi · 44 unmodified lines | |||
| 321 | {/* Provider plugins */} | 324 | {/* Provider plugins */} |
| 322 | {secretProviderPlugins.length > 0 && ( | 325 | {secretProviderPlugins.length > 0 && ( |
| 323 | <div className="mb-10"> | 326 | <div className="mb-10"> |
| 324 | <div className="flex items-center gap-3 mb-4"> | 327 | <CardStack> |
| 325 | <h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/60"> | 328 | <CardStackHeader>Providers</CardStackHeader> |
| 326 | Providers | 329 | <CardStackContent> |
| 327 | </h2> | 330 | {secretProviderPlugins.map((plugin) => ( |
| 328 | <div className="flex-1 h-px bg-border/50" /> | 331 | <Suspense |
| 329 | </div> | 332 | key={plugin.key} |
| 330 | <div className="grid gap-3"> | 333 | fallback={ |
| 331 | {secretProviderPlugins.map((plugin) => ( | 334 | <div className="px-4 py-3 animate-pulse"> |
| 332 | <Suspense | 335 | <div className="h-4 w-24 rounded bg-muted" /> |
| 333 | key={plugin.key} | 336 | </div> |
| 334 | fallback={ | 337 | } |
| 335 | <div className="rounded-xl border border-border/60 bg-card p-5 animate-pulse"> | 338 | > |
| 336 | <div className="h-4 w-24 rounded bg-muted" /> | 339 | <plugin.settings /> |
| 337 | </div> | 340 | </Suspense> |
| 338 | } | 341 | ))} |
| 339 | > | 342 | </CardStackContent> |
| 340 | <plugin.settings /> | 343 | </CardStack> |
| 341 | </Suspense> | ||
| 342 | ))} | ||
| 343 | </div> | ||
| 344 | </div> | 344 | </div> |
| 345 | )} | 345 | )} |
| 346 | 346 | ||
| @@ -357,33 +357,45 @@ export function SecretsPage(props: { secretProviderPlugins: readonly SecretProvi · 10 unmodified lines | |||
| 357 | <p className="text-[13px] text-destructive">Failed to load secrets</p> | 357 | <p className="text-[13px] text-destructive">Failed to load secrets</p> |
| 358 | </div> | 358 | </div> |
| 359 | ), | 359 | ), |
| 360 | onSuccess: ({ value }) => | 360 | onSuccess: ({ value }) => ( |
| 361 | value.length === 0 ? ( | 361 | <CardStack> |
| 362 | <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/70 py-16"> | 362 | <CardStackHeader>Secrets</CardStackHeader> |
| 363 | <p className="text-[13px] font-medium text-foreground/60 mb-1">No secrets yet</p> | 363 | <CardStackContent> |
| 364 | <p className="text-[12px] text-muted-foreground/50 mb-5 max-w-[240px] text-center leading-relaxed"> | 364 | {value.length === 0 ? ( |
| 365 | Add API keys and credentials to authenticate your sources. | 365 | <CardStackEntry> |
| 366 | </p> | 366 | <CardStackEntryContent> |
| 367 | <Button variant="outline" size="sm" onClick={() => setAddOpen(true)}> | 367 | <CardStackEntryDescription> |
| 368 | Add your first secret | 368 | Add API keys and credentials to authenticate your sources. |
| 369 | </Button> | 369 | </CardStackEntryDescription> |
| 370 | </div> | 370 | </CardStackEntryContent> |
| 371 | ) : ( | 371 | <CardStackEntryActions> |
| 372 | <div className="grid gap-1.5"> | 372 | <Button |
| 373 | {value.map((s) => ( | 373 | variant="link" |
| 374 | <SecretRow | 374 | size="sm" |
| 375 | key={s.id} | 375 | className="h-7 px-0 text-[12px]" |
| 376 | secret={{ | 376 | onClick={() => setAddOpen(true)} |
| 377 | id: s.id, | 377 | > |
| 378 | name: s.name, | 378 | Add your first secret |
| 379 | purpose: s.purpose, | 379 | </Button> |
| 380 | provider: s.provider ? String(s.provider) : undefined, | 380 | </CardStackEntryActions> |
| 381 | }} | 381 | </CardStackEntry> |
| 382 | onRemove={() => handleRemove(s.id)} | 382 | ) : ( |
| 383 | /> | 383 | value.map((s) => ( |
| 384 | ))} | 384 | <SecretRow |
| 385 | </div> | 385 | key={s.id} |
| 386 | ), | 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 | <AddSecretDialog open={addOpen} onOpenChange={setAddOpen} /> | 401 | <AddSecretDialog open={addOpen} onOpenChange={setAddOpen} /> |