From 461f08d268303049c3ac2eae3a8e0379b9faaf8a Mon Sep 17 00:00:00 2001 From: mrzmyr Date: Fri, 10 Apr 2026 21:20:52 +0200 Subject: [PATCH 1/4] feat(react): add CardStack primitive and adopt it across surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new `` 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 `
` markup: - Secrets page: each secret row becomes a `` with title/description/actions slots. - Schema explorer: wrapped in ``/`` 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. --- packages/react/src/components/card-stack.tsx | 426 +++++++++++++++++ .../react/src/components/schema-explorer.tsx | 96 ++-- packages/react/src/components/tool-detail.tsx | 110 +++-- packages/react/src/components/tool-tree.tsx | 432 ++++++++++-------- packages/react/src/pages/secrets.tsx | 182 ++++---- 5 files changed, 887 insertions(+), 359 deletions(-) create mode 100644 packages/react/src/components/card-stack.tsx diff --git a/packages/react/src/components/card-stack.tsx b/packages/react/src/components/card-stack.tsx new file mode 100644 index 0000000000..6b82bc66ee --- /dev/null +++ b/packages/react/src/components/card-stack.tsx @@ -0,0 +1,426 @@ +import * as React from "react"; +import { PlusIcon, SearchIcon, XIcon } from "lucide-react"; +import { Collapsible as CollapsiblePrimitive, Slot } from "radix-ui"; + +import { cn } from "../lib/utils"; +import { Label } from "./label"; + +type CardStackContextValue = { + collapsible: boolean; + searchable: boolean; + searchQuery: string; + setSearchQuery: (query: string) => void; +}; + +const CardStackContext = React.createContext({ + collapsible: false, + searchable: false, + searchQuery: "", + setSearchQuery: () => {}, +}); + +type CardStackProps = React.ComponentProps<"div"> & { + collapsible?: boolean; + defaultOpen?: boolean; + open?: boolean; + onOpenChange?: (open: boolean) => void; + /** + * When true, renders a compact search input in the header and filters + * entries whose `searchText` prop does not match the query. + */ + searchable?: boolean; + searchQuery?: string; + defaultSearchQuery?: string; + onSearchChange?: (query: string) => void; +}; + +function CardStack({ + className, + collapsible = false, + defaultOpen, + open, + onOpenChange, + searchable = false, + searchQuery: searchQueryProp, + defaultSearchQuery = "", + onSearchChange, + ...props +}: CardStackProps) { + const [uncontrolledQuery, setUncontrolledQuery] = React.useState(defaultSearchQuery); + const searchQuery = searchQueryProp ?? uncontrolledQuery; + const setSearchQuery = React.useCallback( + (query: string) => { + if (searchQueryProp === undefined) setUncontrolledQuery(query); + onSearchChange?.(query); + }, + [searchQueryProp, onSearchChange], + ); + + const contextValue = React.useMemo( + () => ({ collapsible, searchable, searchQuery, setSearchQuery }), + [collapsible, searchable, searchQuery, setSearchQuery], + ); + + const card = ( +
+ ); + + if (collapsible) { + return ( + + + {card} + + + ); + } + + return ( + {card} + ); +} + +function CardStackSearchInput() { + const { searchQuery, setSearchQuery } = React.useContext(CardStackContext); + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
event.stopPropagation()} + > + + setSearchQuery(event.target.value)} + placeholder="Search…" + aria-label="Search entries" + className="h-5 w-32 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground" + /> + {searchQuery && ( + + )} +
+ ); +} + +type CardStackHeaderProps = React.HTMLAttributes & { + /** + * Content rendered on the right side of the header, after the title and + * (optional) search input. Useful for action buttons like "Add Header". + */ + rightSlot?: React.ReactNode; +}; + +function CardStackHeader({ + className, + children, + rightSlot, + ...props +}: CardStackHeaderProps) { + const { collapsible, searchable } = React.useContext(CardStackContext); + + const title = {children}; + + if (collapsible) { + return ( + + {title} + {searchable && } + {rightSlot} + + + ); + } + + return ( +
+ {title} + {searchable && } + {rightSlot} +
+ ); +} + +function CardStackHeaderAction({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardStackContent({ + className, + ...props +}: React.ComponentProps<"div">) { + const { collapsible } = React.useContext(CardStackContext); + + if (collapsible) { + return ( + +
*+*]:relative [&>*+*]:before:pointer-events-none [&>*+*]:before:absolute [&>*+*]:before:inset-x-0 [&>*+*]:before:top-0 [&>*+*]:before:h-px [&>*+*]:before:bg-border/50", + className, + )} + {...props} + /> + + ); + } + + return ( +
*+*]:relative [&>*+*]:before:pointer-events-none [&>*+*]:before:absolute [&>*+*]:before:inset-x-0 [&>*+*]:before:top-0 [&>*+*]:before:h-px [&>*+*]:before:bg-border/50", + className, + )} + {...props} + /> + ); +} + +type CardStackEntryProps = React.ComponentProps<"div"> & { + asChild?: boolean; + /** + * Text used to match against the parent `CardStack`'s search query when + * `searchable` is enabled. When omitted, the entry is always shown. + */ + searchText?: string; +}; + +function CardStackEntry({ + className, + asChild = false, + searchText, + ...props +}: CardStackEntryProps) { + const { searchable, searchQuery } = React.useContext(CardStackContext); + + if (searchable && searchText !== undefined) { + const trimmed = searchQuery.trim().toLowerCase(); + if (trimmed.length > 0 && !searchText.toLowerCase().includes(trimmed)) { + return null; + } + } + + const Comp = asChild ? Slot.Root : "div"; + return ( + + ); +} + +type CardStackEntryFieldProps = React.ComponentProps<"div"> & { + label?: React.ReactNode; + description?: React.ReactNode; + hint?: React.ReactNode; + labelAction?: React.ReactNode; +}; + +/** + * Form-field variant of `CardStackEntry` — stacks a label, form control, and + * optional hint vertically. Use inside a `CardStack` to render consistent + * bordered form fields. Consumers pass the form control (Input, Textarea, + * etc.) as children. + */ +function CardStackEntryField({ + className, + label, + description, + hint, + labelAction, + children, + ...props +}: CardStackEntryFieldProps) { + return ( +
+ {(label || labelAction) && ( +
+ {label && ( + + )} + {labelAction} +
+ )} + {children} + {hint &&

{hint}

} +
+ ); +} + +function CardStackEntryMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardStackEntryContent({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardStackEntryTitle({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardStackEntryDescription({ + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ ); +} + +function CardStackEntryActions({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +

+ ); +} + +function CardStackEmpty({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + CardStack, + CardStackHeader, + CardStackHeaderAction, + CardStackContent, + CardStackEntry, + CardStackEntryField, + CardStackEntryMedia, + CardStackEntryContent, + CardStackEntryTitle, + CardStackEntryDescription, + CardStackEntryActions, + CardStackEmpty, +}; diff --git a/packages/react/src/components/schema-explorer.tsx b/packages/react/src/components/schema-explorer.tsx index 4870e063a1..10566da9ab 100644 --- a/packages/react/src/components/schema-explorer.tsx +++ b/packages/react/src/components/schema-explorer.tsx @@ -1,5 +1,10 @@ import { useState, useCallback } from "react"; import { ChevronRight } from "lucide-react"; +import { + CardStack, + CardStackHeader, + CardStackContent, +} from "./card-stack"; // --------------------------------------------------------------------------- // JSON Schema types (subset we render) @@ -180,7 +185,7 @@ const mergeAllOf = (schemas: JsonSchema[], root: JsonSchema): JsonSchema => { // Type label styling — plain text, no colored pills // --------------------------------------------------------------------------- -const typeClasses = "font-mono text-[0.6875rem] leading-5 text-muted-foreground/50"; +const typeClasses = "font-mono text-[0.6875rem] leading-5 text-muted-foreground"; // --------------------------------------------------------------------------- // PropertyRow @@ -231,20 +236,20 @@ function PropertyRow(props: { : undefined } className={[ - "flex items-start gap-2 py-2.5 px-3", - expandable ? "cursor-pointer hover:bg-accent/30 transition-colors" : "", + "flex items-start gap-2 py-2.5 px-4", + expandable ? "cursor-pointer hover:bg-accent/40 transition-colors" : "", ].join(" ")} - style={depth > 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined} + style={depth > 0 ? { paddingLeft: `${depth * 16 + 16}px` } : undefined} > {/* Chevron or dot */}
{expandable ? ( ) : ( - + )}
@@ -254,14 +259,14 @@ function PropertyRow(props: {

{typeLabel}

{!hideRequiredBadge && (required ? ( -

+

required

) : ( -

optional

+

optional

))} {schema.default !== undefined && ( -

+

= {JSON.stringify(schema.default)}

)} @@ -271,8 +276,8 @@ function PropertyRow(props: { {/* Description — below the row */} {description && (

{description}

@@ -280,7 +285,7 @@ function PropertyRow(props: { {/* Children — rendered lazily on expand */} {open && expandable && ( -
+
)} @@ -297,7 +302,7 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: if (depth > 6) { return ( -

+

Nested too deep to display.

); @@ -316,7 +321,7 @@ function PropertyChildren(props: { schema: JsonSchema; root: JsonSchema; depth: return a.localeCompare(b); }); return ( -
+
{entries.map(([key, value], i) => (

0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined} + className="px-4 py-2 text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground" + style={depth > 0 ? { paddingLeft: `${depth * 16 + 16}px` } : undefined} > {label}

-
+
{variants.map((variant, i) => ( { + const resolved = deepResolve(schema, schema); + if (resolved.properties) return Object.keys(resolved.properties).length; + if (resolved.allOf) { + const merged = mergeAllOf(resolved.allOf, schema); + return merged.properties ? Object.keys(merged.properties).length : 0; + } + if (resolved.oneOf && resolved.oneOf.length > 1) return resolved.oneOf.length; + if (resolved.anyOf && resolved.anyOf.length > 1) return resolved.anyOf.length; + return 0; +}; + // --------------------------------------------------------------------------- // SchemaExplorer — main export // --------------------------------------------------------------------------- -export function SchemaExplorer(props: { schema: unknown }) { +export function SchemaExplorer(props: { schema: unknown; title?: string }) { const schema = props.schema as JsonSchema | undefined; if (!schema) return null; const hasContent = isExpandable(schema, schema); + const title = props.title; if (!hasContent) { const typeLabel = getTypeLabel(schema, schema); return ( -
-

- {typeLabel} -

-
+ + {title && {title}} + +
+ {typeLabel} +
+
+
); } + const fieldCount = countTopLevelFields(schema); + const countLabel = + fieldCount > 0 ? `${fieldCount} ${fieldCount === 1 ? "field" : "fields"}` : null; + return ( -
- -
+ + {title && ( + + {countLabel} + + ) : undefined + } + > + {title} + + )} + + + + ); } diff --git a/packages/react/src/components/tool-detail.tsx b/packages/react/src/components/tool-detail.tsx index 47acb8c24a..468268a072 100644 --- a/packages/react/src/components/tool-detail.tsx +++ b/packages/react/src/components/tool-detail.tsx @@ -6,8 +6,24 @@ import { Button } from "./button"; import { Markdown } from "./markdown"; import { SchemaExplorer } from "./schema-explorer"; import { ExpandableCodeBlock } from "./expandable-code-block"; +import { + CardStack, + CardStackHeader, + CardStackContent, +} from "./card-stack"; import { Copy, Check, ChevronRight } from "lucide-react"; +function EmptySection(props: { title: string; message: string }) { + return ( + + {props.title} + +

{props.message}

+
+
+ ); +} + // --------------------------------------------------------------------------- // Copy button // --------------------------------------------------------------------------- @@ -92,7 +108,7 @@ export function ToolDetail(props: {
{crumbs.length > 1 && ( -
+
{crumbs.slice(0, -1).map((part, i) => ( {i > 0 && } @@ -106,7 +122,7 @@ export function ToolDetail(props: {
{props.toolDescription && ( -
+
{props.toolDescription}
)} @@ -122,7 +138,7 @@ export function ToolDetail(props: { "border-b-2 pb-2.5 text-sm font-medium transition-colors rounded-none", tab === "schema" ? "border-primary text-foreground" - : "border-transparent text-muted-foreground/50 hover:text-muted-foreground", + : "border-transparent text-muted-foreground hover:text-foreground", ].join(" ")} > Schema @@ -136,7 +152,7 @@ export function ToolDetail(props: { "border-b-2 pb-2.5 text-sm font-medium transition-colors rounded-none", tab === "typescript" ? "border-primary text-foreground" - : "border-transparent text-muted-foreground/50 hover:text-muted-foreground", + : "border-transparent text-muted-foreground hover:text-foreground", ].join(" ")} > TypeScript @@ -152,66 +168,48 @@ export function ToolDetail(props: { onFailure: () =>
Something went wrong
, onSuccess: () => tab === "schema" ? ( -
-
-

- Parameters -

-
- {data?.inputSchema ? ( - - ) : ( -

None

- )} -
-
- -
-

- Response -

-
- {data?.outputSchema ? ( - - ) : ( -

None

- )} -
-
+
+ {data?.inputSchema ? ( + + ) : ( + + )} + {data?.outputSchema ? ( + + ) : ( + + )}
) : ( -
-
-

- Input -

-
- {data?.inputTypeScript ? ( +
+ {data?.inputTypeScript ? ( + + Input + - ) : ( -

void

- )} -
-
- -
-

- Output -

-
- {data?.outputTypeScript ? ( + + + ) : ( + + )} + {data?.outputTypeScript ? ( + + Output + - ) : ( -

void

- )} -
-
+ + + ) : ( + + )}
), })} @@ -228,11 +226,11 @@ export function ToolDetailEmpty(props: { hasTools: boolean }) { return (
-

+

{props.hasTools ? "Select a tool" : "No tools available"}

{props.hasTools && ( -

+

Choose from the list to see what it does.

)} diff --git a/packages/react/src/components/tool-tree.tsx b/packages/react/src/components/tool-tree.tsx index be6cf566dd..354eb77290 100644 --- a/packages/react/src/components/tool-tree.tsx +++ b/packages/react/src/components/tool-tree.tsx @@ -1,6 +1,9 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { ChevronRightIcon, SearchIcon, XIcon } from "lucide-react"; import { Button } from "./button"; import { Input } from "./input"; +import { CardStack, CardStackContent } from "./card-stack"; +import { cn } from "../lib/utils"; // --------------------------------------------------------------------------- // Types @@ -15,24 +18,38 @@ export interface ToolSummary { type TreeNode = { segment: string; + path: string; tool?: ToolSummary; children: Map; }; +type Row = + | { kind: "leaf"; depth: number; path: string; tool: ToolSummary } + | { + kind: "group"; + depth: number; + path: string; + segment: string; + count: number; + open: boolean; + }; + // --------------------------------------------------------------------------- // Tree builder // --------------------------------------------------------------------------- const buildTree = (tools: readonly ToolSummary[]): TreeNode => { - const root: TreeNode = { segment: "", children: new Map() }; + const root: TreeNode = { segment: "", path: "", children: new Map() }; for (const tool of tools) { const parts = tool.name.split("."); let node = root; + let path = ""; for (const part of parts) { + path = path ? `${path}.${part}` : part; let child = node.children.get(part); if (!child) { - child = { segment: part, children: new Map() }; + child = { segment: part, path, children: new Map() }; node.children.set(part, child); } node = child; @@ -51,6 +68,48 @@ const countLeaves = (node: TreeNode): number => { return count; }; +const collectGroupPaths = (node: TreeNode, acc: Set): void => { + for (const child of node.children.values()) { + if (child.children.size > 0) { + acc.add(child.path); + collectGroupPaths(child, acc); + } + } +}; + +const flattenTree = ( + node: TreeNode, + depth: number, + openSet: ReadonlySet, + acc: Row[], +): void => { + const sorted = [...node.children.values()].sort((a, b) => + a.segment.localeCompare(b.segment), + ); + for (const child of sorted) { + const hasChildren = child.children.size > 0; + const isLeaf = !!child.tool && !hasChildren; + + if (isLeaf) { + acc.push({ kind: "leaf", depth, path: child.path, tool: child.tool! }); + continue; + } + + const open = openSet.has(child.path); + acc.push({ + kind: "group", + depth, + path: child.path, + segment: child.segment, + count: countLeaves(child), + open, + }); + if (open) { + flattenTree(child, depth + 1, openSet, acc); + } + } +}; + // --------------------------------------------------------------------------- // Highlight // --------------------------------------------------------------------------- @@ -99,7 +158,7 @@ const highlightMatch = (text: string, search: string) => { <> {parts.map((p, i) => p.hl ? ( - + {p.text} ) : ( @@ -110,157 +169,6 @@ const highlightMatch = (text: string, search: string) => { ); }; -// --------------------------------------------------------------------------- -// Tree node view -// --------------------------------------------------------------------------- - -function TreeNodeView(props: { - node: TreeNode; - depth: number; - selectedToolId: string | null; - onSelect: (toolId: string) => void; - search: string; - defaultOpen: boolean; -}) { - const { node, depth, selectedToolId, onSelect, search, defaultOpen } = props; - const hasChildren = node.children.size > 0; - const isLeaf = !!node.tool && !hasChildren; - - const hasSelectedDescendant = useMemo(() => { - if (!selectedToolId) return false; - const check = (n: TreeNode): boolean => { - if (n.tool?.id === selectedToolId) return true; - for (const child of n.children.values()) { - if (check(child)) return true; - } - return false; - }; - return check(node); - }, [node, selectedToolId]); - - const [open, setOpen] = useState(defaultOpen || hasSelectedDescendant); - - useEffect(() => { - if (defaultOpen || hasSelectedDescendant) setOpen(true); - }, [defaultOpen, hasSelectedDescendant]); - - const paddingLeft = 8 + depth * 16; - - if (isLeaf) { - return ( - onSelect(node.tool!.id)} - search={search} - depth={depth} - /> - ); - } - - const sorted = [...node.children.values()].sort((a, b) => a.segment.localeCompare(b.segment)); - const leafCount = countLeaves(node); - - return ( -
- - - {open && hasChildren && ( -
- - {sorted.map((child) => ( - - ))} -
- )} -
- ); -} - -// --------------------------------------------------------------------------- -// Leaf item -// --------------------------------------------------------------------------- - -function ToolLeafItem(props: { - tool: ToolSummary; - active: boolean; - onSelect: () => void; - search: string; - depth: number; -}) { - const ref = useRef(null); - const paddingLeft = 8 + props.depth * 16 + 8; - const label = props.tool.name.split(".").pop() ?? props.tool.name; - - useEffect(() => { - if (props.active && ref.current) { - ref.current.scrollIntoView({ block: "nearest" }); - } - }, [props.active]); - - return ( - - ); -} - // --------------------------------------------------------------------------- // ToolTree — main export // --------------------------------------------------------------------------- @@ -270,20 +178,61 @@ export function ToolTree(props: { selectedToolId: string | null; onSelect: (toolId: string) => void; }) { + const { tools, selectedToolId, onSelect } = props; const [search, setSearch] = useState(""); + const [manualOpen, setManualOpen] = useState>(() => new Set()); const searchRef = useRef(null); + const selectedRowRef = useRef(null); + const terms = search.trim().toLowerCase().split(/\s+/).filter(Boolean); const filteredTools = useMemo(() => { - if (terms.length === 0) return props.tools; - return props.tools.filter((t) => { + if (terms.length === 0) return tools; + return tools.filter((t) => { const corpus = [t.name, t.description ?? ""].join(" ").toLowerCase(); return terms.every((term) => corpus.includes(term)); }); - }, [props.tools, terms]); + }, [tools, terms]); const tree = useMemo(() => buildTree(filteredTools), [filteredTools]); + // When searching, expand everything so matches are visible. + // Also auto-expand groups that contain the selected tool. + const openSet = useMemo(() => { + if (terms.length > 0) { + const all = new Set(); + collectGroupPaths(tree, all); + return all; + } + const set = new Set(manualOpen); + if (selectedToolId) { + const parts = selectedToolId.split("."); + // Progressively add ancestor paths (best-effort, based on dotted name). + let acc = ""; + for (let i = 0; i < parts.length - 1; i++) { + acc = acc ? `${acc}.${parts[i]}` : parts[i]!; + set.add(acc); + } + } + return set; + }, [tree, manualOpen, selectedToolId, terms.length]); + + const rows = useMemo(() => { + const acc: Row[] = []; + flattenTree(tree, 0, openSet, acc); + return acc; + }, [tree, openSet]); + + const toggleGroup = (path: string) => { + setManualOpen((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + // Keyboard shortcuts — `/` focuses search, Escape clears useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "/" && document.activeElement?.tagName !== "INPUT") { @@ -299,68 +248,151 @@ export function ToolTree(props: { return () => document.removeEventListener("keydown", handler); }, [search]); - const entries = [...tree.children.values()].sort((a, b) => a.segment.localeCompare(b.segment)); + // Scroll the selected row into view when it changes + useEffect(() => { + if (!selectedToolId) return; + selectedRowRef.current?.scrollIntoView({ block: "nearest" }); + }, [selectedToolId, rows]); return ( -
- {/* Search */} -
-
- - - - +
+ + {/* Search header */} +
+ setSearch(e.target.value)} - placeholder={`Filter ${props.tools.length} tools…`} - 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" + placeholder={`Filter ${tools.length} tools…`} + aria-label="Filter tools" + 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" /> {search.length > 0 ? ( ) : ( - + / )}
-
- - {/* Tree */} -
- {filteredTools.length === 0 ? ( -
- {terms.length > 0 ? "No tools match your filter" : "No tools available"} -
- ) : ( -
- {entries.map((node) => ( - 0} - /> - ))} -
- )} -
+ + {/* Tree body */} +
+ {filteredTools.length === 0 ? ( +
+ {terms.length > 0 ? "No tools match your filter" : "No tools available"} +
+ ) : ( + + {rows.map((row) => + row.kind === "leaf" ? ( + onSelect(row.tool.id)} + search={search} + /> + ) : ( + toggleGroup(row.path)} + search={search} + /> + ), + )} + + )} +
+
); } + +// --------------------------------------------------------------------------- +// Row renderers +// --------------------------------------------------------------------------- + +const rowIndent = (depth: number) => 12 + depth * 16; + +const rowBaseClasses = + "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"; + +function ToolGroupRow(props: { + segment: string; + depth: number; + count: number; + open: boolean; + onToggle: () => void; + search: string; +}) { + return ( + + ); +} + +function ToolLeafRow(props: { + buttonRef?: React.Ref; + tool: ToolSummary; + depth: number; + active: boolean; + onSelect: () => void; + search: string; +}) { + const label = props.tool.name.split(".").pop() ?? props.tool.name; + return ( + + ); +} diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index 05306ab096..5de877b907 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -1,5 +1,10 @@ import { useState, Suspense } from "react"; -import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; +import { + useAtomValue, + useAtomSet, + useAtomRefresh, + Result, +} from "@effect-atom/atom-react"; import { secretsAtom, setSecret, removeSecret } from "../api/atoms"; import type { SecretProviderPlugin } from "../plugins/secret-provider-plugin"; import { SecretId } from "@executor/sdk"; @@ -29,12 +34,26 @@ import { SelectTrigger, SelectValue, } from "../components/select"; +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryActions, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryTitle, + CardStackHeader, +} from "../components/card-stack"; +import { Badge } from "../components/badge"; // --------------------------------------------------------------------------- // Add secret dialog // --------------------------------------------------------------------------- -function AddSecretDialog(props: { open: boolean; onOpenChange: (v: boolean) => void }) { +function AddSecretDialog(props: { + open: boolean; + onOpenChange: (v: boolean) => void; +}) { const [id, setId] = useState(""); const [name, setName] = useState(""); const [value, setValue] = useState(""); @@ -93,8 +112,8 @@ function AddSecretDialog(props: { open: boolean; onOpenChange: (v: boolean) => v New secret - Store a credential or API key. Values are kept in your system keychain when available, - with a local encrypted file fallback. + Store a credential or API key. Values are kept in your system + keychain when available, with a local encrypted file fallback. @@ -164,7 +183,9 @@ function AddSecretDialog(props: { open: boolean; onOpenChange: (v: boolean) => v id="secret-purpose" placeholder="GitHub API auth" value={purpose} - onChange={(e) => setPurpose((e.target as HTMLInputElement).value)} + onChange={(e) => + setPurpose((e.target as HTMLInputElement).value) + } className="text-[13px] h-9" />
@@ -225,33 +246,25 @@ function SecretRow(props: { const { secret } = props; return ( -
- {/* Content */} -
-
-

{secret.name}

- - {secret.id} - -
+ + + + {secret.name} + {secret.purpose && ( -

{secret.purpose}

- )} -
- - {/* Provider + actions */} -
- {secret.provider && ( - - {secret.provider} - + +
{secret.purpose}
+
)} + + + {secret.provider && {secret.provider}}
-
+ + ); } @@ -278,7 +291,9 @@ function SecretRow(props: { // Page // --------------------------------------------------------------------------- -export function SecretsPage(props: { secretProviderPlugins: readonly SecretProviderPlugin[] }) { +export function SecretsPage(props: { + secretProviderPlugins: readonly SecretProviderPlugin[]; +}) { const { secretProviderPlugins } = props; const [addOpen, setAddOpen] = useState(false); const scopeId = useScope(); @@ -321,26 +336,23 @@ export function SecretsPage(props: { secretProviderPlugins: readonly SecretProvi {/* Provider plugins */} {secretProviderPlugins.length > 0 && (
-
-

- Providers -

-
-
-
- {secretProviderPlugins.map((plugin) => ( - -
-
- } - > - -
- ))} -
+ + Providers + + {secretProviderPlugins.map((plugin) => ( + +
+
+ } + > + +
+ ))} +
+
)} @@ -349,41 +361,57 @@ export function SecretsPage(props: { secretProviderPlugins: readonly SecretProvi onInitial: () => (
-

Loading secrets…

+

+ Loading secrets… +

), onFailure: () => (
-

Failed to load secrets

+

+ Failed to load secrets +

), - onSuccess: ({ value }) => - value.length === 0 ? ( -
-

No secrets yet

-

- Add API keys and credentials to authenticate your sources. -

- -
- ) : ( -
- {value.map((s) => ( - handleRemove(s.id)} - /> - ))} -
- ), + onSuccess: ({ value }) => ( + + Secrets + + {value.length === 0 ? ( + + + + Add API keys and credentials to authenticate your sources. + + + + + + + ) : ( + value.map((s) => ( + handleRemove(s.id)} + /> + )) + )} + + + ), })} From 3aa5bc83e62deae7c0c135dcf67078240535665e Mon Sep 17 00:00:00 2001 From: mrzmyr Date: Sat, 11 Apr 2026 08:02:49 +0200 Subject: [PATCH 2/4] fix(react): use Input/Button components in CardStack search (oxlint forbid-elements) --- packages/react/src/components/card-stack.tsx | 94 ++++++-------------- 1 file changed, 27 insertions(+), 67 deletions(-) diff --git a/packages/react/src/components/card-stack.tsx b/packages/react/src/components/card-stack.tsx index 6b82bc66ee..ed05ea5c75 100644 --- a/packages/react/src/components/card-stack.tsx +++ b/packages/react/src/components/card-stack.tsx @@ -3,6 +3,8 @@ import { PlusIcon, SearchIcon, XIcon } from "lucide-react"; import { Collapsible as CollapsiblePrimitive, Slot } from "radix-ui"; import { cn } from "../lib/utils"; +import { Button } from "./button"; +import { Input } from "./input"; import { Label } from "./label"; type CardStackContextValue = { @@ -88,9 +90,7 @@ function CardStack({ ); } - return ( - {card} - ); + return {card}; } function CardStackSearchInput() { @@ -103,23 +103,25 @@ function CardStackSearchInput() { onClick={(event) => event.stopPropagation()} > - setSearchQuery(event.target.value)} placeholder="Search…" aria-label="Search entries" - className="h-5 w-32 bg-transparent text-xs text-foreground outline-none placeholder:text-muted-foreground" + 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" /> {searchQuery && ( - + )}
); @@ -133,12 +135,7 @@ type CardStackHeaderProps = React.HTMLAttributes & { rightSlot?: React.ReactNode; }; -function CardStackHeader({ - className, - children, - rightSlot, - ...props -}: CardStackHeaderProps) { +function CardStackHeader({ className, children, rightSlot, ...props }: CardStackHeaderProps) { const { collapsible, searchable } = React.useContext(CardStackContext); const title = {children}; @@ -180,33 +177,22 @@ function CardStackHeader({ ); } -function CardStackHeaderAction({ - className, - ...props -}: React.ComponentProps<"div">) { +function CardStackHeaderAction({ className, ...props }: React.ComponentProps<"div">) { return (
); } -function CardStackContent({ - className, - ...props -}: React.ComponentProps<"div">) { +function CardStackContent({ className, ...props }: React.ComponentProps<"div">) { const { collapsible } = React.useContext(CardStackContext); if (collapsible) { return ( - +
*+*]:relative [&>*+*]:before:pointer-events-none [&>*+*]:before:absolute [&>*+*]:before:inset-x-0 [&>*+*]:before:top-0 [&>*+*]:before:h-px [&>*+*]:before:bg-border/50", - className, - )} + "flex flex-col border-t border-border/50 first:border-t-0", + "[&>*+*]:relative [&>*+*]:before:pointer-events-none [&>*+*]:before:absolute [&>*+*]:before:inset-x-0 [&>*+*]:before:top-0 [&>*+*]:before:h-px [&>*+*]:before:bg-border/50", + className, + )} {...props} /> ); @@ -242,12 +228,7 @@ type CardStackEntryProps = React.ComponentProps<"div"> & { searchText?: string; }; -function CardStackEntry({ - className, - asChild = false, - searchText, - ...props -}: CardStackEntryProps) { +function CardStackEntry({ className, asChild = false, searchText, ...props }: CardStackEntryProps) { const { searchable, searchQuery } = React.useContext(CardStackContext); if (searchable && searchText !== undefined) { @@ -310,10 +291,7 @@ function CardStackEntryField({ )} @@ -326,10 +304,7 @@ function CardStackEntryField({ ); } -function CardStackEntryMedia({ - className, - ...props -}: React.ComponentProps<"div">) { +function CardStackEntryMedia({ className, ...props }: React.ComponentProps<"div">) { return (
) { +function CardStackEntryContent({ className, ...props }: React.ComponentProps<"div">) { return (
) { +function CardStackEntryTitle({ className, ...props }: React.ComponentProps<"div">) { return (
) { +function CardStackEntryDescription({ className, ...props }: React.ComponentProps<"p">) { return (

) { +function CardStackEntryActions({ className, ...props }: React.ComponentProps<"div">) { return (

); From 6c2349a551b6af90936f3c809852bc30389e9820 Mon Sep 17 00:00:00 2001 From: mrzmyr Date: Sat, 11 Apr 2026 08:02:53 +0200 Subject: [PATCH 3/4] 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 --- apps/cloud/src/routes/billing_.plans.tsx | 6 +++--- packages/plugins/openapi/src/sdk/real-specs.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cloud/src/routes/billing_.plans.tsx b/apps/cloud/src/routes/billing_.plans.tsx index 9bba213343..67f28f9db2 100644 --- a/apps/cloud/src/routes/billing_.plans.tsx +++ b/apps/cloud/src/routes/billing_.plans.tsx @@ -48,8 +48,8 @@ function PlansPage() { const isLoading = customerLoading || plansLoading; - const paidPlans = (plans ?? ([] as Plan[])).filter( - (p) => p.id === "hobby" || p.id === "professional", + const paidPlans: Plan[] = (plans ?? ([] as Plan[])).filter( + (p: Plan) => p.id === "hobby" || p.id === "professional", ); return ( @@ -91,7 +91,7 @@ function PlansPage() { isFetching ? "opacity-50 pointer-events-none" : "", ].join(" ")} > - {paidPlans.map((plan) => { + {paidPlans.map((plan: Plan) => { const meta = PLAN_META[plan.id]; if (!meta) return null; diff --git a/packages/plugins/openapi/src/sdk/real-specs.test.ts b/packages/plugins/openapi/src/sdk/real-specs.test.ts index bfaf271b5e..0d9bf7a540 100644 --- a/packages/plugins/openapi/src/sdk/real-specs.test.ts +++ b/packages/plugins/openapi/src/sdk/real-specs.test.ts @@ -36,7 +36,7 @@ const getResult = () => return cachedResult; }); -describe("Real specs: Cloudflare API", () => { +describe("Real specs: Cloudflare API", { timeout: 60_000 }, () => { it.effect("parses the full Cloudflare spec", () => Effect.gen(function* () { const doc = yield* getDoc(); From fce8ad7b10a95aff835a6e7506a30aa48b816505 Mon Sep 17 00:00:00 2001 From: mrzmyr Date: Sat, 11 Apr 2026 08:02:57 +0200 Subject: [PATCH 4/4] style: apply oxfmt formatting --- .../core/execution/src/tool-invoker.test.ts | 87 +++++++++---------- .../react/src/components/schema-explorer.tsx | 6 +- packages/react/src/components/tool-detail.tsx | 6 +- packages/react/src/components/tool-tree.tsx | 12 +-- packages/react/src/pages/secrets.tsx | 32 ++----- 5 files changed, 53 insertions(+), 90 deletions(-) diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index b9b752f243..38543c8ea2 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -366,52 +366,45 @@ describe("pause/resume with multiple elicitations", () => { // Regression: use separate top-level runPromise calls to match HTTP/CLI // pause/resume, and a single-elicit tool so no later pause can mask a dead // sandbox fiber. - it( - "resume returns across separate runPromise boundaries for a single-elicit tool (HTTP-like)", - async () => { - const executor = await Effect.runPromise(makeElicitingExecutor()); - const engine = createExecutionEngine({ executor }); - - const code = "return await tools.api.singleApproval({});"; - - const outcome1 = await engine.executeWithPause(code); - expect(outcome1.status).toBe("paused"); - const paused1 = outcome1 as Extract; - expect(paused1.execution.elicitationContext.request.message).toBe("Only approval"); - - // `execution.fiber` is on `InternalPausedExecution`; the exported - // `PausedExecution` type doesn't carry it. Cast to read. - const sandboxFiber = ( - paused1.execution as unknown as { - readonly fiber: Fiber.Fiber; - } - ).fiber; - const exitProbe = await Effect.runPromise( - Effect.race( - Fiber.await(sandboxFiber), - Effect.map(Effect.sleep("50 millis"), () => "still-running" as const), - ), - ); - expect(exitProbe).toBe("still-running"); - - const outcome2 = await Promise.race([ - engine.resume(paused1.execution.id, { action: "accept" }), - new Promise((_, reject) => - setTimeout( - () => reject(new Error("resume hung across runPromise boundaries")), - 2000, - ), - ), - ]); - - expect(outcome2).not.toBeNull(); - const resumed = outcome2 as NonNullable; - expect(resumed.status).toBe("completed"); - if (resumed.status === "completed") { - expect(resumed.result.error).toBeUndefined(); - expect(resumed.result.result).toMatchObject({ ok: true }); + it("resume returns across separate runPromise boundaries for a single-elicit tool (HTTP-like)", async () => { + const executor = await Effect.runPromise(makeElicitingExecutor()); + const engine = createExecutionEngine({ executor }); + + const code = "return await tools.api.singleApproval({});"; + + const outcome1 = await engine.executeWithPause(code); + expect(outcome1.status).toBe("paused"); + const paused1 = outcome1 as Extract; + expect(paused1.execution.elicitationContext.request.message).toBe("Only approval"); + + // `execution.fiber` is on `InternalPausedExecution`; the exported + // `PausedExecution` type doesn't carry it. Cast to read. + const sandboxFiber = ( + paused1.execution as unknown as { + readonly fiber: Fiber.Fiber; } - }, - 10000, - ); + ).fiber; + const exitProbe = await Effect.runPromise( + Effect.race( + Fiber.await(sandboxFiber), + Effect.map(Effect.sleep("50 millis"), () => "still-running" as const), + ), + ); + expect(exitProbe).toBe("still-running"); + + const outcome2 = await Promise.race([ + engine.resume(paused1.execution.id, { action: "accept" }), + new Promise((_, reject) => + setTimeout(() => reject(new Error("resume hung across runPromise boundaries")), 2000), + ), + ]); + + expect(outcome2).not.toBeNull(); + const resumed = outcome2 as NonNullable; + expect(resumed.status).toBe("completed"); + if (resumed.status === "completed") { + expect(resumed.result.error).toBeUndefined(); + expect(resumed.result.result).toMatchObject({ ok: true }); + } + }, 10000); }); diff --git a/packages/react/src/components/schema-explorer.tsx b/packages/react/src/components/schema-explorer.tsx index 10566da9ab..c5341f012b 100644 --- a/packages/react/src/components/schema-explorer.tsx +++ b/packages/react/src/components/schema-explorer.tsx @@ -1,10 +1,6 @@ import { useState, useCallback } from "react"; import { ChevronRight } from "lucide-react"; -import { - CardStack, - CardStackHeader, - CardStackContent, -} from "./card-stack"; +import { CardStack, CardStackHeader, CardStackContent } from "./card-stack"; // --------------------------------------------------------------------------- // JSON Schema types (subset we render) diff --git a/packages/react/src/components/tool-detail.tsx b/packages/react/src/components/tool-detail.tsx index 468268a072..3f48af66fa 100644 --- a/packages/react/src/components/tool-detail.tsx +++ b/packages/react/src/components/tool-detail.tsx @@ -6,11 +6,7 @@ import { Button } from "./button"; import { Markdown } from "./markdown"; import { SchemaExplorer } from "./schema-explorer"; import { ExpandableCodeBlock } from "./expandable-code-block"; -import { - CardStack, - CardStackHeader, - CardStackContent, -} from "./card-stack"; +import { CardStack, CardStackHeader, CardStackContent } from "./card-stack"; import { Copy, Check, ChevronRight } from "lucide-react"; function EmptySection(props: { title: string; message: string }) { diff --git a/packages/react/src/components/tool-tree.tsx b/packages/react/src/components/tool-tree.tsx index 354eb77290..832ea660de 100644 --- a/packages/react/src/components/tool-tree.tsx +++ b/packages/react/src/components/tool-tree.tsx @@ -83,9 +83,7 @@ const flattenTree = ( openSet: ReadonlySet, acc: Row[], ): void => { - const sorted = [...node.children.values()].sort((a, b) => - a.segment.localeCompare(b.segment), - ); + const sorted = [...node.children.values()].sort((a, b) => a.segment.localeCompare(b.segment)); for (const child of sorted) { const hasChildren = child.children.size > 0; const isLeaf = !!child.tool && !hasChildren; @@ -297,9 +295,7 @@ export function ToolTree(props: { row.kind === "leaf" ? ( {highlightMatch(props.segment, props.search)} - - {props.count} - + {props.count} ); } diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index 5de877b907..e1d80d179c 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -1,10 +1,5 @@ import { useState, Suspense } from "react"; -import { - useAtomValue, - useAtomSet, - useAtomRefresh, - Result, -} from "@effect-atom/atom-react"; +import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; import { secretsAtom, setSecret, removeSecret } from "../api/atoms"; import type { SecretProviderPlugin } from "../plugins/secret-provider-plugin"; import { SecretId } from "@executor/sdk"; @@ -50,10 +45,7 @@ import { Badge } from "../components/badge"; // Add secret dialog // --------------------------------------------------------------------------- -function AddSecretDialog(props: { - open: boolean; - onOpenChange: (v: boolean) => void; -}) { +function AddSecretDialog(props: { open: boolean; onOpenChange: (v: boolean) => void }) { const [id, setId] = useState(""); const [name, setName] = useState(""); const [value, setValue] = useState(""); @@ -112,8 +104,8 @@ function AddSecretDialog(props: { New secret - Store a credential or API key. Values are kept in your system - keychain when available, with a local encrypted file fallback. + Store a credential or API key. Values are kept in your system keychain when available, + with a local encrypted file fallback. @@ -183,9 +175,7 @@ function AddSecretDialog(props: { id="secret-purpose" placeholder="GitHub API auth" value={purpose} - onChange={(e) => - setPurpose((e.target as HTMLInputElement).value) - } + onChange={(e) => setPurpose((e.target as HTMLInputElement).value)} className="text-[13px] h-9" />
@@ -291,9 +281,7 @@ function SecretRow(props: { // Page // --------------------------------------------------------------------------- -export function SecretsPage(props: { - secretProviderPlugins: readonly SecretProviderPlugin[]; -}) { +export function SecretsPage(props: { secretProviderPlugins: readonly SecretProviderPlugin[] }) { const { secretProviderPlugins } = props; const [addOpen, setAddOpen] = useState(false); const scopeId = useScope(); @@ -361,16 +349,12 @@ export function SecretsPage(props: { onInitial: () => (
-

- Loading secrets… -

+

Loading secrets…

), onFailure: () => (
-

- Failed to load secrets -

+

Failed to load secrets

), onSuccess: ({ value }) => (