History

feat(react): add CardStack primitive and adopt it across surfaces (#165)

* 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

Moritz Meyer committed 7 months ago

Commit f6d694b1 parent 939ea7b5 files · +809 −351Browse files

Unified · Split

5 files
  1. packages/react/src/components/card-stack.tsx +386 −0
  2. packages/react/src/components/schema-explorer.tsx +66 −26
  3. packages/react/src/components/tool-detail.tsx +50 −56
  4. packages/react/src/components/tool-tree.tsx +226 −200
  5. packages/react/src/pages/secrets.tsx +81 −69
packages/react/src/components/card-stack.tsx+386−0
@@ -0,0 +1,386 @@
1import * as React from "react";
2import { PlusIcon, SearchIcon, XIcon } from "lucide-react";
3import { Collapsible as CollapsiblePrimitive, Slot } from "radix-ui";
4
5import { cn } from "../lib/utils";
6import { Button } from "./button";
7import { Input } from "./input";
8import { Label } from "./label";
9
10type CardStackContextValue = {
11 collapsible: boolean;
12 searchable: boolean;
13 searchQuery: string;
14 setSearchQuery: (query: string) => void;
15};
16
17const CardStackContext = React.createContext<CardStackContextValue>({
18 collapsible: false,
19 searchable: false,
20 searchQuery: "",
21 setSearchQuery: () => {},
22});
23
24type 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
39function 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
96function 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
130type 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
138function 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
180function 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
190function 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
222type 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
231function 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
256type 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 */
269function 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
307function 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
320function 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
330function 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
340function 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
350function 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
360function 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
373export {
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};
packages/react/src/components/schema-explorer.tsx+66−26
@@ -1,5 +1,6 @@
1import { useState, useCallback } from "react";1import { useState, useCallback } from "react";
2import { ChevronRight } from "lucide-react";2import { ChevronRight } from "lucide-react";
3import { 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 pills181// Type label styling — plain text, no colored pills
181// ---------------------------------------------------------------------------182// ---------------------------------------------------------------------------
182 183
183const typeClasses = "font-mono text-[0.6875rem] leading-5 text-muted-foreground/50";184const typeClasses = "font-mono text-[0.6875rem] leading-5 text-muted-foreground";
184 185
185// ---------------------------------------------------------------------------186// ---------------------------------------------------------------------------
186// PropertyRow187// PropertyRow
@@ -231,20 +232,20 @@ function PropertyRow(props: { · 44 unmodified lines
231 : undefined232 : 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 <ChevronRight243 <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 required259 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 <p274 <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 <PropertyRow322 <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 <p359 <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 <PropertyRow367 <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
402const 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 export415// SchemaExplorer — main export
399// ---------------------------------------------------------------------------416// ---------------------------------------------------------------------------
400 417
401export function SchemaExplorer(props: { schema: unknown }) {418export 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}
packages/react/src/components/tool-detail.tsx+50−56
@@ -6,8 +6,20 @@ import { Button } from "./button"; · 5 unmodified lines
6import { Markdown } from "./markdown";6import { Markdown } from "./markdown";
7import { SchemaExplorer } from "./schema-explorer";7import { SchemaExplorer } from "./schema-explorer";
8import { ExpandableCodeBlock } from "./expandable-code-block";8import { ExpandableCodeBlock } from "./expandable-code-block";
9import { CardStack, CardStackHeader, CardStackContent } from "./card-stack";
9import { Copy, Check, ChevronRight } from "lucide-react";10import { Copy, Check, ChevronRight } from "lucide-react";
10 11
12function 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 button24// 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 Schema140 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 TypeScript154 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 Parameters170 ) : (
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 Input183 <CardStackHeader>Input</CardStackHeader>
187 </h4>184 <CardStackContent>
188 <div className="mt-2.5">
189 {data?.inputTypeScript ? (
190 <ExpandableCodeBlock185 <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 Output198 <CardStackContent>
203 </h4>
204 <div className="mt-2.5">
205 {data?.outputTypeScript ? (
206 <ExpandableCodeBlock199 <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 )}
packages/react/src/components/tool-tree.tsx+226−200
@@ -1,6 +1,9 @@
1import { useEffect, useMemo, useRef, useState } from "react";1import { useEffect, useMemo, useRef, useState } from "react";
2import { ChevronRightIcon, SearchIcon, XIcon } from "lucide-react";
2import { Button } from "./button";3import { Button } from "./button";
3import { Input } from "./input";4import { Input } from "./input";
5import { CardStack, CardStackContent } from "./card-stack";
6import { cn } from "../lib/utils";
4 7
5// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
6// Types9// Types
@@ -15,24 +18,38 @@ export interface ToolSummary { · 8 unmodified lines
15 18
16type TreeNode = {19type 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
26type 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 builder38// Tree builder
24// ---------------------------------------------------------------------------39// ---------------------------------------------------------------------------
25 40
26const buildTree = (tools: readonly ToolSummary[]): TreeNode => {41const 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
71const 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
80const 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// Highlight112// 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
117function 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
219function 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 export171// 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 <Input261 <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 <Button270 <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 <TreeNodeView296 <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
329const rowIndent = (depth: number) => 12 + depth * 16;
330
331const 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
334function 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
365function 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}
packages/react/src/pages/secrets.tsx+81−69
@@ -29,6 +29,17 @@ import { · 28 unmodified lines
29 SelectTrigger,29 SelectTrigger,
30 SelectValue,30 SelectValue,
31} from "../components/select";31} from "../components/select";
32import {
33 CardStack,
34 CardStackContent,
35 CardStackEntry,
36 CardStackEntryActions,
37 CardStackEntryContent,
38 CardStackEntryDescription,
39 CardStackEntryTitle,
40 CardStackHeader,
41} from "../components/card-stack";
42import { Badge } from "../components/badge";
32 43
33// ---------------------------------------------------------------------------44// ---------------------------------------------------------------------------
34// Add secret dialog45// 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 <Button254 <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 Providers329 <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 <Suspense335 <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 secret368 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 <SecretRow374 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} />