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 @@
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+};
packages/react/src/components/schema-explorer.tsx+66−26
@@ -1,5 +1,6 @@
11 import { useState, useCallback } from "react";
22 import { ChevronRight } from "lucide-react";
3+import { CardStack, CardStackHeader, CardStackContent } from "./card-stack";
34
45 // ---------------------------------------------------------------------------
56 // JSON Schema types (subset we render)
@@ -180,7 +181,7 @@ const mergeAllOf = (schemas: JsonSchema[], root: JsonSchema): JsonSchema => { · 174 unmodified lines
180181 // Type label styling — plain text, no colored pills
181182 // ---------------------------------------------------------------------------
182183
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";
184185
185186 // ---------------------------------------------------------------------------
186187 // PropertyRow
@@ -231,20 +232,20 @@ function PropertyRow(props: { · 44 unmodified lines
231232 : undefined
232233 }
233234 className={[
234− "flex items-start gap-2 py-2.5 px-3",
235− expandable ? "cursor-pointer hover:bg-accent/30 transition-colors" : "",
235+ "flex items-start gap-2 py-2.5 px-4",
236+ expandable ? "cursor-pointer hover:bg-accent/40 transition-colors" : "",
236237 ].join(" ")}
237− style={depth > 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}
238+ style={depth > 0 ? { paddingLeft: `${depth * 16 + 16}px` } : undefined}
238239 >
239240 {/* Chevron or dot */}
240241 <div className="mt-0.5 flex size-4 shrink-0 items-center justify-center">
241242 {expandable ? (
242243 <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"
244245 style={open ? { transform: "rotate(90deg)" } : undefined}
245246 />
246247 ) : (
247− <span className="size-1 rounded-full bg-muted-foreground/15" />
248+ <span className="size-1 rounded-full bg-muted-foreground/40" />
248249 )}
249250 </div>
250251
@@ -254,14 +255,14 @@ function PropertyRow(props: { · 3 unmodified lines
254255 <p className={typeClasses}>{typeLabel}</p>
255256 {!hideRequiredBadge &&
256257 (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">
258259 required
259260 </p>
260261 ) : (
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>
262263 ))}
263264 {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">
265266 = {JSON.stringify(schema.default)}
266267 </p>
267268 )}
@@ -271,8 +272,8 @@ function PropertyRow(props: { · 3 unmodified lines
271272 {/* Description — below the row */}
272273 {description && (
273274 <p
274− className="px-3 pb-2 text-[0.8125rem] leading-5 text-muted-foreground/50"
275− style={{ paddingLeft: `${depth * 16 + 32}px` }}
275+ className="pr-4 pb-2.5 text-[0.8125rem] leading-5 text-muted-foreground"
276+ style={{ paddingLeft: `${depth * 16 + 36}px` }}
276277 >
277278 {description}
278279 </p>
@@ -280,7 +281,7 @@ function PropertyRow(props: { · 1 unmodified line
280281
281282 {/* Children — rendered lazily on expand */}
282283 {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` }}>
284285 <PropertyChildren schema={childSchema} root={root} depth={depth + 1} />
285286 </div>
286287 )}
@@ -297,7 +298,7 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: · 10 unmodified lines
297298
298299 if (depth > 6) {
299300 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">
301302 Nested too deep to display.
302303 </p>
303304 );
@@ -316,7 +317,7 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: · 12 unmodified lines
316317 return a.localeCompare(b);
317318 });
318319 return (
319− <div className="divide-y divide-border/20">
320+ <div className="divide-y divide-border/50">
320321 {entries.map(([key, value], i) => (
321322 <PropertyRow
322323 key={key}
@@ -356,12 +357,12 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: · 33 unmodified lines
356357 return (
357358 <div>
358359 <p
359− className="px-3 py-2 text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/30"
360− style={depth > 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined}
360+ className="px-4 py-2 text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground"
361+ style={depth > 0 ? { paddingLeft: `${depth * 16 + 16}px` } : undefined}
361362 >
362363 {label}
363364 </p>
364− <div className="divide-y divide-border/20">
365+ <div className="divide-y divide-border/50">
365366 {variants.map((variant, i) => (
366367 <PropertyRow
367368 key={i}
@@ -394,30 +395,69 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: · 26 unmodified lines
394395 return null;
395396 }
396397
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+
397414 // ---------------------------------------------------------------------------
398415 // SchemaExplorer — main export
399416 // ---------------------------------------------------------------------------
400417
401−export function SchemaExplorer(props: { schema: unknown }) {
418+export function SchemaExplorer(props: { schema: unknown; title?: string }) {
402419 const schema = props.schema as JsonSchema | undefined;
403420 if (!schema) return null;
404421
405422 const hasContent = isExpandable(schema, schema);
423+ const title = props.title;
406424
407425 if (!hasContent) {
408426 const typeLabel = getTypeLabel(schema, schema);
409427 return (
410− <div className="rounded-lg border border-border/40 px-4 py-3">
411− <p className="text-sm text-muted-foreground/50">
412− <span className={typeClasses}>{typeLabel}</span>
413− </p>
414− </div>
428+ <CardStack>
429+ {title && <CardStackHeader>{title}</CardStackHeader>}
430+ <CardStackContent>
431+ <div className="flex items-center gap-2 px-4 py-3">
432+ <span className={typeClasses}>{typeLabel}</span>
433+ </div>
434+ </CardStackContent>
435+ </CardStack>
415436 );
416437 }
417438
439+ const fieldCount = countTopLevelFields(schema);
440+ const countLabel =
441+ fieldCount > 0 ? `${fieldCount} ${fieldCount === 1 ? "field" : "fields"}` : null;
442+
418443 return (
419− <div className="rounded-lg border border-border/40 overflow-hidden divide-y divide-border/20">
420− <PropertyChildren schema={schema} root={schema} depth={0} />
421− </div>
444+ <CardStack>
445+ {title && (
446+ <CardStackHeader
447+ rightSlot={
448+ countLabel ? (
449+ <span className="shrink-0 tabular-nums text-[11px] text-muted-foreground">
450+ {countLabel}
451+ </span>
452+ ) : undefined
453+ }
454+ >
455+ {title}
456+ </CardStackHeader>
457+ )}
458+ <CardStackContent>
459+ <PropertyChildren schema={schema} root={schema} depth={0} />
460+ </CardStackContent>
461+ </CardStack>
422462 );
423463 }
packages/react/src/components/tool-detail.tsx+50−56
@@ -6,8 +6,20 @@ import { Button } from "./button"; · 5 unmodified lines
66 import { Markdown } from "./markdown";
77 import { SchemaExplorer } from "./schema-explorer";
88 import { ExpandableCodeBlock } from "./expandable-code-block";
9+import { CardStack, CardStackHeader, CardStackContent } from "./card-stack";
910 import { Copy, Check, ChevronRight } from "lucide-react";
1011
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+
1123 // ---------------------------------------------------------------------------
1224 // Copy button
1325 // ---------------------------------------------------------------------------
@@ -92,7 +104,7 @@ export function ToolDetail(props: { · 78 unmodified lines
92104 <div className="shrink-0 border-b border-border/40">
93105 <div className="px-5 pt-4 pb-0">
94106 {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">
96108 {crumbs.slice(0, -1).map((part, i) => (
97109 <span key={i} className="flex items-center gap-1">
98110 {i > 0 && <ChevronRight className="size-3 shrink-0" />}
@@ -106,7 +118,7 @@ export function ToolDetail(props: { · 7 unmodified lines
106118 <CopyButton text={props.toolId} label="Copy tool ID" />
107119 </div>
108120 {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">
110122 <Markdown>{props.toolDescription}</Markdown>
111123 </div>
112124 )}
@@ -122,7 +134,7 @@ export function ToolDetail(props: { · 9 unmodified lines
122134 "border-b-2 pb-2.5 text-sm font-medium transition-colors rounded-none",
123135 tab === "schema"
124136 ? "border-primary text-foreground"
125− : "border-transparent text-muted-foreground/50 hover:text-muted-foreground",
137+ : "border-transparent text-muted-foreground hover:text-foreground",
126138 ].join(" ")}
127139 >
128140 Schema
@@ -136,7 +148,7 @@ export function ToolDetail(props: { · 7 unmodified lines
136148 "border-b-2 pb-2.5 text-sm font-medium transition-colors rounded-none",
137149 tab === "typescript"
138150 ? "border-primary text-foreground"
139− : "border-transparent text-muted-foreground/50 hover:text-muted-foreground",
151+ : "border-transparent text-muted-foreground hover:text-foreground",
140152 ].join(" ")}
141153 >
142154 TypeScript
@@ -152,66 +164,48 @@ export function ToolDetail(props: { · 9 unmodified lines
152164 onFailure: () => <div className="p-5 text-sm text-destructive">Something went wrong</div>,
153165 onSuccess: () =>
154166 tab === "schema" ? (
155− <div className="px-5 py-5 space-y-6">
156− <section>
157− <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40">
158− Parameters
159− </h4>
160− <div className="mt-2.5">
161− {data?.inputSchema ? (
162− <SchemaExplorer schema={data.inputSchema} />
163− ) : (
164− <p className="py-2 text-sm text-muted-foreground/40">None</p>
165− )}
166− </div>
167− </section>
168−
169− <section>
170− <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40">
171− Response
172− </h4>
173− <div className="mt-2.5">
174− {data?.outputSchema ? (
175− <SchemaExplorer schema={data.outputSchema} />
176− ) : (
177− <p className="py-2 text-sm text-muted-foreground/40">None</p>
178− )}
179− </div>
180− </section>
167+ <div className="px-5 py-5 space-y-5">
168+ {data?.inputSchema ? (
169+ <SchemaExplorer schema={data.inputSchema} title="Parameters" />
170+ ) : (
171+ <EmptySection title="Parameters" message="None" />
172+ )}
173+ {data?.outputSchema ? (
174+ <SchemaExplorer schema={data.outputSchema} title="Response" />
175+ ) : (
176+ <EmptySection title="Response" message="None" />
177+ )}
181178 </div>
182179 ) : (
183− <div className="px-5 py-5 space-y-6">
184− <section>
185− <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40">
186− Input
187− </h4>
188− <div className="mt-2.5">
189− {data?.inputTypeScript ? (
180+ <div className="px-5 py-5 space-y-5">
181+ {data?.inputTypeScript ? (
182+ <CardStack>
183+ <CardStackHeader>Input</CardStackHeader>
184+ <CardStackContent>
190185 <ExpandableCodeBlock
191186 code={data.inputTypeScript}
192187 definitions={data.definitions}
188+ className="rounded-none border-0"
193189 />
194− ) : (
195− <p className="py-2 text-sm text-muted-foreground/40">void</p>
196− )}
197− </div>
198− </section>
199−
200− <section>
201− <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40">
202− Output
203− </h4>
204− <div className="mt-2.5">
205− {data?.outputTypeScript ? (
190+ </CardStackContent>
191+ </CardStack>
192+ ) : (
193+ <EmptySection title="Input" message="void" />
194+ )}
195+ {data?.outputTypeScript ? (
196+ <CardStack>
197+ <CardStackHeader>Output</CardStackHeader>
198+ <CardStackContent>
206199 <ExpandableCodeBlock
207200 code={data.outputTypeScript}
208201 definitions={data.definitions}
202+ className="rounded-none border-0"
209203 />
210− ) : (
211− <p className="py-2 text-sm text-muted-foreground/40">void</p>
212− )}
213− </div>
214− </section>
204+ </CardStackContent>
205+ </CardStack>
206+ ) : (
207+ <EmptySection title="Output" message="void" />
208+ )}
215209 </div>
216210 ),
217211 })}
@@ -228,11 +222,11 @@ export function ToolDetailEmpty(props: { hasTools: boolean }) { · 10 unmodified lines
228222 return (
229223 <div className="flex h-full items-center justify-center">
230224 <div className="text-center">
231− <p className="text-sm font-medium text-foreground/70">
225+ <p className="text-sm font-medium text-foreground">
232226 {props.hasTools ? "Select a tool" : "No tools available"}
233227 </p>
234228 {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">
236230 Choose from the list to see what it does.
237231 </p>
238232 )}
packages/react/src/components/tool-tree.tsx+226−200
@@ -1,6 +1,9 @@
11 import { useEffect, useMemo, useRef, useState } from "react";
2+import { ChevronRightIcon, SearchIcon, XIcon } from "lucide-react";
23 import { Button } from "./button";
34 import { Input } from "./input";
5+import { CardStack, CardStackContent } from "./card-stack";
6+import { cn } from "../lib/utils";
47
58 // ---------------------------------------------------------------------------
69 // Types
@@ -15,24 +18,38 @@ export interface ToolSummary { · 8 unmodified lines
1518
1619 type TreeNode = {
1720 segment: string;
21+ path: string;
1822 tool?: ToolSummary;
1923 children: Map<string, TreeNode>;
2024 };
2125
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+
2237 // ---------------------------------------------------------------------------
2338 // Tree builder
2439 // ---------------------------------------------------------------------------
2540
2641 const buildTree = (tools: readonly ToolSummary[]): TreeNode => {
27− const root: TreeNode = { segment: "", children: new Map() };
42+ const root: TreeNode = { segment: "", path: "", children: new Map() };
2843
2944 for (const tool of tools) {
3045 const parts = tool.name.split(".");
3146 let node = root;
47+ let path = "";
3248 for (const part of parts) {
49+ path = path ? `${path}.${part}` : part;
3350 let child = node.children.get(part);
3451 if (!child) {
35− child = { segment: part, children: new Map() };
52+ child = { segment: part, path, children: new Map() };
3653 node.children.set(part, child);
3754 }
3855 node = child;
@@ -51,6 +68,46 @@ const countLeaves = (node: TreeNode): number => { · 12 unmodified lines
5168 return count;
5269 };
5370
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+
54111 // ---------------------------------------------------------------------------
55112 // Highlight
56113 // ---------------------------------------------------------------------------
@@ -99,7 +156,7 @@ const highlightMatch = (text: string, search: string) => { · 42 unmodified lines
99156 <>
100157 {parts.map((p, i) =>
101158 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">
103160 {p.text}
104161 </mark>
105162 ) : (
@@ -110,157 +167,6 @@ const highlightMatch = (text: string, search: string) => { · 4 unmodified lines
110167 );
111168 };
112169
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−
264170 // ---------------------------------------------------------------------------
265171 // ToolTree — main export
266172 // ---------------------------------------------------------------------------
@@ -270,20 +176,61 @@ export function ToolTree(props: { · 3 unmodified lines
270176 selectedToolId: string | null;
271177 onSelect: (toolId: string) => void;
272178 }) {
179+ const { tools, selectedToolId, onSelect } = props;
273180 const [search, setSearch] = useState("");
181+ const [manualOpen, setManualOpen] = useState<Set<string>>(() => new Set());
274182 const searchRef = useRef<HTMLInputElement>(null);
183+ const selectedRowRef = useRef<HTMLButtonElement>(null);
184+
275185 const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
276186
277187 const filteredTools = useMemo(() => {
278− if (terms.length === 0) return props.tools;
279− return props.tools.filter((t) => {
188+ if (terms.length === 0) return tools;
189+ return tools.filter((t) => {
280190 const corpus = [t.name, t.description ?? ""].join(" ").toLowerCase();
281191 return terms.every((term) => corpus.includes(term));
282192 });
283− }, [props.tools, terms]);
193+ }, [tools, terms]);
284194
285195 const tree = useMemo(() => buildTree(filteredTools), [filteredTools]);
286196
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
287234 useEffect(() => {
288235 const handler = (e: KeyboardEvent) => {
289236 if (e.key === "/" && document.activeElement?.tagName !== "INPUT") {
@@ -299,68 +246,147 @@ export function ToolTree(props: { · 9 unmodified lines
299246 return () => document.removeEventListener("keydown", handler);
300247 }, [search]);
301248
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]);
303254
304255 return (
305− <div className="flex h-full flex-col">
306− {/* Search */}
307− <div className="shrink-0 border-b border-border px-3 py-2">
308− <div className="flex h-8 items-center gap-2 rounded-md border border-input bg-background px-2.5">
309− <svg viewBox="0 0 16 16" className="size-3.5 shrink-0 text-muted-foreground/40">
310− <circle cx="6.5" cy="6.5" r="5" fill="none" stroke="currentColor" strokeWidth="1.2" />
311− <path
312− d="M10 10l4.5 4.5"
313− stroke="currentColor"
314− strokeWidth="1.2"
315− strokeLinecap="round"
316− />
317− </svg>
256+ <div className="flex h-full min-h-0 flex-col p-3">
257+ <CardStack className="flex min-h-0 flex-1 flex-col">
258+ {/* Search header */}
259+ <div className="flex shrink-0 items-center gap-2 border-b border-border/60 px-3 py-2">
260+ <SearchIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
318261 <Input
319262 ref={searchRef}
320263 value={search}
321264 onChange={(e) => setSearch(e.target.value)}
322− placeholder={`Filter ${props.tools.length} tools…`}
323− className="min-w-0 flex-1 border-0 bg-transparent p-0 text-[13px] shadow-none outline-none placeholder:text-muted-foreground/40 h-auto rounded-none focus-visible:ring-0 focus-visible:border-transparent"
265+ placeholder={`Filter ${tools.length} tools…`}
266+ aria-label="Filter tools"
267+ className="h-auto min-w-0 flex-1 rounded-none border-0 bg-transparent p-0 text-[13px] shadow-none outline-none placeholder:text-muted-foreground focus-visible:border-transparent focus-visible:ring-0"
324268 />
325269 {search.length > 0 ? (
326270 <Button
327271 variant="ghost"
328272 size="icon-xs"
329273 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"
331276 >
332− ×
277+ <XIcon className="size-3.5" />
333278 </Button>
334279 ) : (
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">
336281 /
337282 </kbd>
338283 )}
339284 </div>
340− </div>
341−
342− {/* Tree */}
343− <div className="flex-1 overflow-y-auto p-1.5">
344− {filteredTools.length === 0 ? (
345− <div className="p-4 text-center text-[13px] text-muted-foreground/50">
346− {terms.length > 0 ? "No tools match your filter" : "No tools available"}
347− </div>
348− ) : (
349− <div className="flex flex-col gap-px">
350− {entries.map((node) => (
351− <TreeNodeView
352− key={node.segment}
353− node={node}
354− depth={0}
355− selectedToolId={props.selectedToolId}
356− onSelect={props.onSelect}
357− search={search}
358− defaultOpen={terms.length > 0}
359− />
360− ))}
361− </div>
362− )}
363− </div>
285+
286+ {/* Tree body */}
287+ <div className="min-h-0 flex-1 overflow-y-auto">
288+ {filteredTools.length === 0 ? (
289+ <div className="p-4 text-center text-[13px] text-muted-foreground">
290+ {terms.length > 0 ? "No tools match your filter" : "No tools available"}
291+ </div>
292+ ) : (
293+ <CardStackContent>
294+ {rows.map((row) =>
295+ row.kind === "leaf" ? (
296+ <ToolLeafRow
297+ key={row.path}
298+ buttonRef={row.tool.id === selectedToolId ? selectedRowRef : undefined}
299+ tool={row.tool}
300+ depth={row.depth}
301+ active={row.tool.id === selectedToolId}
302+ onSelect={() => onSelect(row.tool.id)}
303+ search={search}
304+ />
305+ ) : (
306+ <ToolGroupRow
307+ key={row.path}
308+ segment={row.segment}
309+ depth={row.depth}
310+ count={row.count}
311+ open={row.open}
312+ onToggle={() => toggleGroup(row.path)}
313+ search={search}
314+ />
315+ ),
316+ )}
317+ </CardStackContent>
318+ )}
319+ </div>
320+ </CardStack>
364321 </div>
365322 );
366323 }
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+}
packages/react/src/pages/secrets.tsx+81−69
@@ -29,6 +29,17 @@ import { · 28 unmodified lines
2929 SelectTrigger,
3030 SelectValue,
3131 } 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";
3243
3344 // ---------------------------------------------------------------------------
3445 // Add secret dialog
@@ -225,33 +236,25 @@ function SecretRow(props: { · 190 unmodified lines
225236 const { secret } = props;
226237
227238 return (
228− <div className="group relative flex items-center gap-4 rounded-lg border border-border/60 bg-card px-4 py-3 transition-all hover:border-border hover:shadow-[0_1px_3px_rgba(0,0,0,0.04)]">
229− {/* Content */}
230− <div className="min-w-0 flex-1">
231− <div className="flex items-center gap-2">
232− <p className="text-[13px] font-medium text-foreground leading-none">{secret.name}</p>
233− <span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] leading-none text-muted-foreground">
234− {secret.id}
235− </span>
236− </div>
239+ <CardStackEntry>
240+ <CardStackEntryContent>
241+ <CardStackEntryTitle className="flex items-center gap-2">
242+ <span className="truncate">{secret.name}</span>
243+ </CardStackEntryTitle>
237244 {secret.purpose && (
238− <p className="mt-1 text-[12px] text-muted-foreground/70 leading-none">{secret.purpose}</p>
239− )}
240− </div>
241−
242− {/* Provider + actions */}
243− <div className="flex items-center gap-1.5">
244− {secret.provider && (
245− <span className="rounded-full border border-border/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground leading-none">
246− {secret.provider}
247− </span>
245+ <CardStackEntryDescription>
246+ <div className="flex gap-2 flex-col text-xs">{secret.purpose}</div>
247+ </CardStackEntryDescription>
248248 )}
249+ </CardStackEntryContent>
250+ <CardStackEntryActions>
251+ {secret.provider && <Badge variant="outline">{secret.provider}</Badge>}
249252 <DropdownMenu>
250253 <DropdownMenuTrigger asChild>
251254 <Button
252255 variant="ghost"
253256 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"
255258 >
256259 <svg viewBox="0 0 16 16" className="size-3">
257260 <circle cx="8" cy="3" r="1.2" fill="currentColor" />
@@ -269,8 +272,8 @@ function SecretRow(props: { · 11 unmodified lines
269272 </DropdownMenuItem>
270273 </DropdownMenuContent>
271274 </DropdownMenu>
272− </div>
273− </div>
275+ </CardStackEntryActions>
276+ </CardStackEntry>
274277 );
275278 }
276279
@@ -321,26 +324,23 @@ export function SecretsPage(props: { secretProviderPlugins: readonly SecretProvi · 44 unmodified lines
321324 {/* Provider plugins */}
322325 {secretProviderPlugins.length > 0 && (
323326 <div className="mb-10">
324− <div className="flex items-center gap-3 mb-4">
325− <h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/60">
326− Providers
327− </h2>
328− <div className="flex-1 h-px bg-border/50" />
329− </div>
330− <div className="grid gap-3">
331− {secretProviderPlugins.map((plugin) => (
332− <Suspense
333− key={plugin.key}
334− fallback={
335− <div className="rounded-xl border border-border/60 bg-card p-5 animate-pulse">
336− <div className="h-4 w-24 rounded bg-muted" />
337− </div>
338− }
339− >
340− <plugin.settings />
341− </Suspense>
342− ))}
343− </div>
327+ <CardStack>
328+ <CardStackHeader>Providers</CardStackHeader>
329+ <CardStackContent>
330+ {secretProviderPlugins.map((plugin) => (
331+ <Suspense
332+ key={plugin.key}
333+ fallback={
334+ <div className="px-4 py-3 animate-pulse">
335+ <div className="h-4 w-24 rounded bg-muted" />
336+ </div>
337+ }
338+ >
339+ <plugin.settings />
340+ </Suspense>
341+ ))}
342+ </CardStackContent>
343+ </CardStack>
344344 </div>
345345 )}
346346
@@ -357,33 +357,45 @@ export function SecretsPage(props: { secretProviderPlugins: readonly SecretProvi · 10 unmodified lines
357357 <p className="text-[13px] text-destructive">Failed to load secrets</p>
358358 </div>
359359 ),
360− onSuccess: ({ value }) =>
361− value.length === 0 ? (
362− <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border/70 py-16">
363− <p className="text-[13px] font-medium text-foreground/60 mb-1">No secrets yet</p>
364− <p className="text-[12px] text-muted-foreground/50 mb-5 max-w-[240px] text-center leading-relaxed">
365− Add API keys and credentials to authenticate your sources.
366− </p>
367− <Button variant="outline" size="sm" onClick={() => setAddOpen(true)}>
368− Add your first secret
369− </Button>
370− </div>
371− ) : (
372− <div className="grid gap-1.5">
373− {value.map((s) => (
374− <SecretRow
375− key={s.id}
376− secret={{
377− id: s.id,
378− name: s.name,
379− purpose: s.purpose,
380− provider: s.provider ? String(s.provider) : undefined,
381− }}
382− onRemove={() => handleRemove(s.id)}
383− />
384− ))}
385− </div>
386− ),
360+ onSuccess: ({ value }) => (
361+ <CardStack>
362+ <CardStackHeader>Secrets</CardStackHeader>
363+ <CardStackContent>
364+ {value.length === 0 ? (
365+ <CardStackEntry>
366+ <CardStackEntryContent>
367+ <CardStackEntryDescription>
368+ Add API keys and credentials to authenticate your sources.
369+ </CardStackEntryDescription>
370+ </CardStackEntryContent>
371+ <CardStackEntryActions>
372+ <Button
373+ variant="link"
374+ size="sm"
375+ className="h-7 px-0 text-[12px]"
376+ onClick={() => setAddOpen(true)}
377+ >
378+ Add your first secret
379+ </Button>
380+ </CardStackEntryActions>
381+ </CardStackEntry>
382+ ) : (
383+ value.map((s) => (
384+ <SecretRow
385+ key={s.id}
386+ secret={{
387+ id: s.id,
388+ name: s.name,
389+ purpose: s.purpose,
390+ provider: s.provider ? String(s.provider) : undefined,
391+ }}
392+ onRemove={() => handleRemove(s.id)}
393+ />
394+ ))
395+ )}
396+ </CardStackContent>
397+ </CardStack>
398+ ),
387399 })}
388400
389401 <AddSecretDialog open={addOpen} onOpenChange={setAddOpen} />