Redesign tools detail view with interactive schema explorer
Unified · Split
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | HttpApiBuilder, | 7 | HttpApiBuilder, |
| 8 | HttpApiSwagger, | 8 | HttpApiSwagger, |
| 9 | HttpMiddleware, | 9 | HttpMiddleware, |
| 10 | HttpRouter, | ||
| 10 | HttpServer, | 11 | HttpServer, |
| 11 | } from "@effect/platform"; | 12 | } from "@effect/platform"; |
| 12 | import { Effect, Layer, Scope } from "effect"; | 13 | import { Effect, Layer, Scope } from "effect"; |
| @@ -76,6 +77,7 @@ const SharedServicesMemoized = Effect.runSync( · 63 unmodified lines | |||
| 76 | const publicApiHandler = HttpApiBuilder.toWebHandler( | 77 | const publicApiHandler = HttpApiBuilder.toWebHandler( |
| 77 | PublicCloudApiLive.pipe( | 78 | PublicCloudApiLive.pipe( |
| 78 | Layer.provideMerge(SharedServicesMemoized), | 79 | Layer.provideMerge(SharedServicesMemoized), |
| 80 | Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })), | ||
| 79 | ), | 81 | ), |
| 80 | { middleware: HttpMiddleware.logger }, | 82 | { middleware: HttpMiddleware.logger }, |
| 81 | ); | 83 | ); |
| @@ -184,6 +186,7 @@ const createProtectedHandler = ( · 102 unmodified lines | |||
| 184 | Layer.provideMerge(ProtectedCloudApiLive), | 186 | Layer.provideMerge(ProtectedCloudApiLive), |
| 185 | Layer.provideMerge(requestServices), | 187 | Layer.provideMerge(requestServices), |
| 186 | Layer.provideMerge(SharedServicesMemoized), | 188 | Layer.provideMerge(SharedServicesMemoized), |
| 189 | Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })), | ||
| 187 | ), | 190 | ), |
| 188 | { middleware: HttpMiddleware.logger }, | 191 | { middleware: HttpMiddleware.logger }, |
| 189 | ); | 192 | ); |
| @@ -17,6 +17,9 @@ export const Route = createRootRoute({ · 16 unmodified lines | |||
| 17 | { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=Instrument+Serif&family=JetBrains+Mono:wght@400;500&display=swap" }, | 17 | { rel: "stylesheet", href: "https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..700;1,9..40,300..700&family=Instrument+Serif&family=JetBrains+Mono:wght@400;500&display=swap" }, |
| 18 | { rel: "stylesheet", href: appCss }, | 18 | { rel: "stylesheet", href: appCss }, |
| 19 | ], | 19 | ], |
| 20 | scripts: import.meta.env.DEV | ||
| 21 | ? [{ src: "https://ui.sh/ui-picker.js" }] | ||
| 22 | : [], | ||
| 20 | }), | 23 | }), |
| 21 | component: RootComponent, | 24 | component: RootComponent, |
| 22 | shellComponent: RootDocument, | 25 | shellComponent: RootDocument, |
| @@ -2,6 +2,7 @@ import { · 1 unmodified line | |||
| 2 | HttpApiBuilder, | 2 | HttpApiBuilder, |
| 3 | HttpApiSwagger, | 3 | HttpApiSwagger, |
| 4 | HttpMiddleware, | 4 | HttpMiddleware, |
| 5 | HttpRouter, | ||
| 5 | HttpServer, | 6 | HttpServer, |
| 6 | } from "@effect/platform"; | 7 | } from "@effect/platform"; |
| 7 | import { Context, Effect, Layer, ManagedRuntime } from "effect"; | 8 | import { Context, Effect, Layer, ManagedRuntime } from "effect"; |
| @@ -76,6 +77,7 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 68 unmodified lines | |||
| 76 | Layer.provideMerge(Layer.succeed(ExecutorService, executor)), | 77 | Layer.provideMerge(Layer.succeed(ExecutorService, executor)), |
| 77 | Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)), | 78 | Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)), |
| 78 | Layer.provideMerge(HttpServer.layerContext), | 79 | Layer.provideMerge(HttpServer.layerContext), |
| 80 | Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })), | ||
| 79 | ), | 81 | ), |
| 80 | { middleware: HttpMiddleware.logger }, | 82 | { middleware: HttpMiddleware.logger }, |
| 81 | ); | 83 | ); |
| @@ -33,6 +33,8 @@ const ToolSchemaResponse = Schema.Struct({ · 32 unmodified lines | |||
| 33 | typeScriptDefinitions: Schema.optional( | 33 | typeScriptDefinitions: Schema.optional( |
| 34 | Schema.Record({ key: Schema.String, value: Schema.String }), | 34 | Schema.Record({ key: Schema.String, value: Schema.String }), |
| 35 | ), | 35 | ), |
| 36 | inputSchema: Schema.optional(Schema.Unknown), | ||
| 37 | outputSchema: Schema.optional(Schema.Unknown), | ||
| 36 | }); | 38 | }); |
| 37 | 39 | ||
| 38 | // --------------------------------------------------------------------------- | 40 | // --------------------------------------------------------------------------- |
| @@ -9,7 +9,7 @@ import type { · 8 unmodified lines | |||
| 9 | InvokeOptions, | 9 | InvokeOptions, |
| 10 | RuntimeToolHandler, | 10 | RuntimeToolHandler, |
| 11 | } from "../tools"; | 11 | } from "../tools"; |
| 12 | import { normalizeRefs } from "../schema-refs"; | 12 | import { normalizeRefs, reattachDefs } from "../schema-refs"; |
| 13 | import { buildToolTypeScriptPreview } from "../schema-types"; | 13 | import { buildToolTypeScriptPreview } from "../schema-types"; |
| 14 | 14 | ||
| 15 | export const makeInMemoryToolRegistry = () => { | 15 | export const makeInMemoryToolRegistry = () => { |
| @@ -68,11 +68,19 @@ export const makeInMemoryToolRegistry = () => { · 52 unmodified lines | |||
| 68 | inputSchema: t.inputSchema, | 68 | inputSchema: t.inputSchema, |
| 69 | outputSchema: t.outputSchema, | 69 | outputSchema: t.outputSchema, |
| 70 | defs, | 70 | defs, |
| 71 | options: { | ||
| 72 | maxLength: Infinity, | ||
| 73 | maxProperties: Infinity, | ||
| 74 | maxCompositeMembers: Infinity, | ||
| 75 | maxRefDepth: 20, | ||
| 76 | }, | ||
| 71 | }); | 77 | }); |
| 72 | 78 | ||
| 73 | return { | 79 | return { |
| 74 | id: t.id, | 80 | id: t.id, |
| 75 | ...typeScriptPreview, | 81 | ...typeScriptPreview, |
| 82 | inputSchema: t.inputSchema ? reattachDefs(t.inputSchema, defs) : undefined, | ||
| 83 | outputSchema: t.outputSchema ? reattachDefs(t.outputSchema, defs) : undefined, | ||
| 76 | }; | 84 | }; |
| 77 | }), | 85 | }), |
| 78 | ), | 86 | ), |
| @@ -36,6 +36,8 @@ export class ToolSchema extends Schema.Class<ToolSchema>("ToolSchema")({ · 35 unmodified lines | |||
| 36 | typeScriptDefinitions: Schema.optional( | 36 | typeScriptDefinitions: Schema.optional( |
| 37 | Schema.Record({ key: Schema.String, value: Schema.String }), | 37 | Schema.Record({ key: Schema.String, value: Schema.String }), |
| 38 | ), | 38 | ), |
| 39 | inputSchema: Schema.optional(Schema.Unknown), | ||
| 40 | outputSchema: Schema.optional(Schema.Unknown), | ||
| 39 | }) {} | 41 | }) {} |
| 40 | 42 | ||
| 41 | export class ToolInvocationResult extends Schema.Class<ToolInvocationResult>( | 43 | export class ToolInvocationResult extends Schema.Class<ToolInvocationResult>( |
| @@ -12,7 +12,7 @@ import type { · 11 unmodified lines | |||
| 12 | InvokeOptions, | 12 | InvokeOptions, |
| 13 | RuntimeToolHandler, | 13 | RuntimeToolHandler, |
| 14 | } from "@executor/sdk"; | 14 | } from "@executor/sdk"; |
| 15 | import { buildToolTypeScriptPreview } from "@executor/sdk"; | 15 | import { buildToolTypeScriptPreview, reattachDefs } from "@executor/sdk"; |
| 16 | 16 | ||
| 17 | // --------------------------------------------------------------------------- | 17 | // --------------------------------------------------------------------------- |
| 18 | // Serialization — leverage ToolRegistration Schema.Class directly | 18 | // Serialization — leverage ToolRegistration Schema.Class directly |
| @@ -101,11 +101,19 @@ export const makeKvToolRegistry = ( · 82 unmodified lines | |||
| 101 | inputSchema: t.inputSchema, | 101 | inputSchema: t.inputSchema, |
| 102 | outputSchema: t.outputSchema, | 102 | outputSchema: t.outputSchema, |
| 103 | defs, | 103 | defs, |
| 104 | options: { | ||
| 105 | maxLength: Infinity, | ||
| 106 | maxProperties: Infinity, | ||
| 107 | maxCompositeMembers: Infinity, | ||
| 108 | maxRefDepth: 20, | ||
| 109 | }, | ||
| 104 | }); | 110 | }); |
| 105 | 111 | ||
| 106 | return { | 112 | return { |
| 107 | id: t.id, | 113 | id: t.id, |
| 108 | ...typeScriptPreview, | 114 | ...typeScriptPreview, |
| 115 | inputSchema: t.inputSchema ? reattachDefs(t.inputSchema, defs) : undefined, | ||
| 116 | outputSchema: t.outputSchema ? reattachDefs(t.outputSchema, defs) : undefined, | ||
| 109 | }; | 117 | }; |
| 110 | }), | 118 | }), |
| 111 | 119 | ||
| @@ -0,0 +1,380 @@ | |||
| 1 | import { useCallback, useEffect, useMemo, useState, startTransition } from "react"; | ||
| 2 | import { getHighlighter, THEME } from "../lib/shiki"; | ||
| 3 | import { cn } from "../lib/utils"; | ||
| 4 | import type { ThemedToken } from "shiki/core"; | ||
| 5 | |||
| 6 | // --------------------------------------------------------------------------- | ||
| 7 | // Types | ||
| 8 | // --------------------------------------------------------------------------- | ||
| 9 | |||
| 10 | type Definition = { | ||
| 11 | readonly name: string; | ||
| 12 | /** The full type body string, e.g. `{ login: string; id: number }` */ | ||
| 13 | readonly code: string; | ||
| 14 | }; | ||
| 15 | |||
| 16 | // --------------------------------------------------------------------------- | ||
| 17 | // Simple TypeScript formatter — expands single-line types into readable form | ||
| 18 | // --------------------------------------------------------------------------- | ||
| 19 | |||
| 20 | const formatTypeScript = (code: string): string => { | ||
| 21 | let result = ""; | ||
| 22 | let indent = 0; | ||
| 23 | let i = 0; | ||
| 24 | const len = code.length; | ||
| 25 | |||
| 26 | // Track brace depth so we only break `|` and `&` at the top level of a union | ||
| 27 | let braceDepth = 0; | ||
| 28 | |||
| 29 | while (i < len) { | ||
| 30 | const ch = code[i]!; | ||
| 31 | |||
| 32 | if (ch === "{") { | ||
| 33 | braceDepth++; | ||
| 34 | indent++; | ||
| 35 | result += "{\n" + " ".repeat(indent); | ||
| 36 | i++; | ||
| 37 | while (i < len && code[i] === " ") i++; | ||
| 38 | } else if (ch === "}") { | ||
| 39 | braceDepth = Math.max(0, braceDepth - 1); | ||
| 40 | indent = Math.max(0, indent - 1); | ||
| 41 | result += "\n" + " ".repeat(indent) + "}"; | ||
| 42 | i++; | ||
| 43 | } else if (ch === ";" && i + 1 < len && code[i + 1] === " ") { | ||
| 44 | result += ";\n" + " ".repeat(indent); | ||
| 45 | i += 2; | ||
| 46 | } else if (ch === ";" && indent > 0) { | ||
| 47 | result += ";\n" + " ".repeat(indent); | ||
| 48 | i++; | ||
| 49 | while (i < len && code[i] === " ") i++; | ||
| 50 | } else if (ch === "|" && i > 0 && code[i - 1] === " " && i + 1 < len && code[i + 1] === " ") { | ||
| 51 | // Union operator ` | ` — break onto new line | ||
| 52 | result += "\n" + " ".repeat(indent) + "| "; | ||
| 53 | i += 2; // skip the operator and trailing space | ||
| 54 | } else { | ||
| 55 | result += ch; | ||
| 56 | i++; | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | return result; | ||
| 61 | }; | ||
| 62 | |||
| 63 | // --------------------------------------------------------------------------- | ||
| 64 | // Copy icons | ||
| 65 | // --------------------------------------------------------------------------- | ||
| 66 | |||
| 67 | const CopyIcon = () => ( | ||
| 68 | <svg viewBox="0 0 16 16" className="size-3"> | ||
| 69 | <rect x="5" y="5" width="8" height="8" rx="1" fill="none" stroke="currentColor" strokeWidth="1.2" /> | ||
| 70 | <path d="M3 11V3h8" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> | ||
| 71 | </svg> | ||
| 72 | ); | ||
| 73 | |||
| 74 | const CheckIcon = () => ( | ||
| 75 | <svg viewBox="0 0 16 16" className="size-3"> | ||
| 76 | <path d="M3 8l3 3 7-7" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /> | ||
| 77 | </svg> | ||
| 78 | ); | ||
| 79 | |||
| 80 | // --------------------------------------------------------------------------- | ||
| 81 | // Shiki tokenization hook — non-blocking | ||
| 82 | // --------------------------------------------------------------------------- | ||
| 83 | |||
| 84 | function useTokens(code: string): ThemedToken[][] | null { | ||
| 85 | const [tokens, setTokens] = useState<ThemedToken[][] | null>(null); | ||
| 86 | |||
| 87 | useEffect(() => { | ||
| 88 | let cancelled = false; | ||
| 89 | getHighlighter().then((highlighter) => { | ||
| 90 | if (cancelled) return; | ||
| 91 | const result = highlighter.codeToTokens(code, { | ||
| 92 | lang: "typescript", | ||
| 93 | theme: THEME, | ||
| 94 | }); | ||
| 95 | if (!cancelled) { | ||
| 96 | startTransition(() => setTokens(result.tokens)); | ||
| 97 | } | ||
| 98 | }); | ||
| 99 | return () => { cancelled = true; }; | ||
| 100 | }, [code]); | ||
| 101 | |||
| 102 | return tokens; | ||
| 103 | } | ||
| 104 | |||
| 105 | // --------------------------------------------------------------------------- | ||
| 106 | // Inline-expand logic | ||
| 107 | // | ||
| 108 | // When a user clicks a ref name like `NonEmptyTrimmedString`, we replace | ||
| 109 | // that name with the resolved body inline. So `firstName: NonEmptyTrimmedString` | ||
| 110 | // becomes `firstName: string`. | ||
| 111 | // | ||
| 112 | // We rebuild the full code string with expansions applied, then re-tokenize. | ||
| 113 | // --------------------------------------------------------------------------- | ||
| 114 | |||
| 115 | /** | ||
| 116 | * Given a code string, a set of ref names to expand, and their definitions, | ||
| 117 | * produce a new code string with each expanded ref replaced by its body. | ||
| 118 | * Applies recursively — if an expanded body itself contains expanded refs, | ||
| 119 | * those get replaced too. Tracks ancestors to prevent infinite loops. | ||
| 120 | */ | ||
| 121 | const applyExpansions = ( | ||
| 122 | code: string, | ||
| 123 | expanded: ReadonlySet<string>, | ||
| 124 | definitions: ReadonlyMap<string, string>, | ||
| 125 | ancestors: ReadonlySet<string>, | ||
| 126 | ): string => { | ||
| 127 | if (expanded.size === 0) return code; | ||
| 128 | |||
| 129 | let result = ""; | ||
| 130 | let i = 0; | ||
| 131 | |||
| 132 | // Sort expanded names longest-first to match greedily | ||
| 133 | const sortedNames = [...expanded].filter((n) => !ancestors.has(n)).sort((a, b) => b.length - a.length); | ||
| 134 | |||
| 135 | while (i < code.length) { | ||
| 136 | let matched = false; | ||
| 137 | |||
| 138 | for (const name of sortedNames) { | ||
| 139 | if (code.startsWith(name, i)) { | ||
| 140 | const body = definitions.get(name); | ||
| 141 | if (body) { | ||
| 142 | // Recursively expand the body, adding this name to ancestors | ||
| 143 | const childAncestors = new Set(ancestors); | ||
| 144 | childAncestors.add(name); | ||
| 145 | result += applyExpansions(body, expanded, definitions, childAncestors); | ||
| 146 | i += name.length; | ||
| 147 | matched = true; | ||
| 148 | break; | ||
| 149 | } | ||
| 150 | } | ||
| 151 | } | ||
| 152 | |||
| 153 | if (!matched) { | ||
| 154 | result += code[i]; | ||
| 155 | i++; | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | return result; | ||
| 160 | }; | ||
| 161 | |||
| 162 | // --------------------------------------------------------------------------- | ||
| 163 | // Split tokens to find ref names that can be clicked | ||
| 164 | // --------------------------------------------------------------------------- | ||
| 165 | |||
| 166 | type RenderToken = | ||
| 167 | | { kind: "text"; content: string; color?: string } | ||
| 168 | | { kind: "ref"; name: string; color?: string }; | ||
| 169 | |||
| 170 | const splitToken = ( | ||
| 171 | token: ThemedToken, | ||
| 172 | clickableNames: ReadonlySet<string>, | ||
| 173 | ): RenderToken[] => { | ||
| 174 | if (clickableNames.size === 0) { | ||
| 175 | return [{ kind: "text", content: token.content, color: token.color }]; | ||
| 176 | } | ||
| 177 | |||
| 178 | const text = token.content; | ||
| 179 | const results: RenderToken[] = []; | ||
| 180 | let remaining = text; | ||
| 181 | |||
| 182 | while (remaining.length > 0) { | ||
| 183 | let earliest = -1; | ||
| 184 | let matchedName = ""; | ||
| 185 | for (const name of clickableNames) { | ||
| 186 | const idx = remaining.indexOf(name); | ||
| 187 | if (idx !== -1 && (earliest === -1 || idx < earliest || (idx === earliest && name.length > matchedName.length))) { | ||
| 188 | earliest = idx; | ||
| 189 | matchedName = name; | ||
| 190 | } | ||
| 191 | } | ||
| 192 | |||
| 193 | if (earliest === -1) { | ||
| 194 | results.push({ kind: "text", content: remaining, color: token.color }); | ||
| 195 | break; | ||
| 196 | } | ||
| 197 | |||
| 198 | if (earliest > 0) { | ||
| 199 | results.push({ kind: "text", content: remaining.slice(0, earliest), color: token.color }); | ||
| 200 | } | ||
| 201 | results.push({ kind: "ref", name: matchedName, color: token.color }); | ||
| 202 | remaining = remaining.slice(earliest + matchedName.length); | ||
| 203 | } | ||
| 204 | |||
| 205 | return results; | ||
| 206 | }; | ||
| 207 | |||
| 208 | // --------------------------------------------------------------------------- | ||
| 209 | // HighlightedCode — renders tokenized code with clickable unexpanded refs | ||
| 210 | // --------------------------------------------------------------------------- | ||
| 211 | |||
| 212 | function HighlightedCode(props: { | ||
| 213 | tokens: ThemedToken[][]; | ||
| 214 | /** Ref names that are NOT yet expanded and can be clicked */ | ||
| 215 | clickableNames: ReadonlySet<string>; | ||
| 216 | onToggle: (name: string) => void; | ||
| 217 | expanded: ReadonlySet<string>; | ||
| 218 | }) { | ||
| 219 | const { tokens, clickableNames, onToggle, expanded } = props; | ||
| 220 | |||
| 221 | return ( | ||
| 222 | <> | ||
| 223 | {tokens.map((line, li) => ( | ||
| 224 | <span key={li}> | ||
| 225 | {li > 0 && "\n"} | ||
| 226 | {line.map((token, ti) => { | ||
| 227 | const parts = splitToken(token, clickableNames); | ||
| 228 | return parts.map((part, pi) => { | ||
| 229 | if (part.kind === "text") { | ||
| 230 | return ( | ||
| 231 | <span key={`${ti}-${pi}`} style={part.color ? { color: part.color } : undefined}> | ||
| 232 | {part.content} | ||
| 233 | </span> | ||
| 234 | ); | ||
| 235 | } | ||
| 236 | |||
| 237 | const isExpanded = expanded.has(part.name); | ||
| 238 | |||
| 239 | return ( | ||
| 240 | <span | ||
| 241 | key={`${ti}-${pi}`} | ||
| 242 | role="button" | ||
| 243 | tabIndex={0} | ||
| 244 | onClick={(e) => { e.stopPropagation(); onToggle(part.name); }} | ||
| 245 | onKeyDown={(e) => { | ||
| 246 | if (e.key === "Enter" || e.key === " ") { | ||
| 247 | e.preventDefault(); | ||
| 248 | onToggle(part.name); | ||
| 249 | } | ||
| 250 | }} | ||
| 251 | className={cn( | ||
| 252 | "cursor-pointer underline underline-offset-2 hover:opacity-80", | ||
| 253 | isExpanded | ||
| 254 | ? "decoration-current/50" | ||
| 255 | : "decoration-current/30", | ||
| 256 | )} | ||
| 257 | style={part.color ? { color: part.color } : undefined} | ||
| 258 | title={isExpanded ? `Collapse ${part.name}` : `Expand ${part.name}`} | ||
| 259 | > | ||
| 260 | {part.name} | ||
| 261 | </span> | ||
| 262 | ); | ||
| 263 | }); | ||
| 264 | })} | ||
| 265 | </span> | ||
| 266 | ))} | ||
| 267 | </> | ||
| 268 | ); | ||
| 269 | } | ||
| 270 | |||
| 271 | // --------------------------------------------------------------------------- | ||
| 272 | // ExpandableCodeBlock — main export | ||
| 273 | // --------------------------------------------------------------------------- | ||
| 274 | |||
| 275 | export function ExpandableCodeBlock(props: { | ||
| 276 | code: string; | ||
| 277 | definitions?: readonly Definition[]; | ||
| 278 | className?: string; | ||
| 279 | }) { | ||
| 280 | const { code, definitions = [], className } = props; | ||
| 281 | // Auto-expand trivial aliases (primitives, simple unions, string literals) | ||
| 282 | const trivialNames = useMemo(() => { | ||
| 283 | const trivial = new Set<string>(); | ||
| 284 | const trivialPattern = /^(?:string|number|boolean|null|undefined|void|never|unknown|any|true|false|(?:"[^"]*"(?:\s*\|\s*"[^"]*")*))$/; | ||
| 285 | for (const d of definitions) { | ||
| 286 | const body = d.code.trim(); | ||
| 287 | if (trivialPattern.test(body)) { | ||
| 288 | trivial.add(d.name); | ||
| 289 | } | ||
| 290 | } | ||
| 291 | return trivial; | ||
| 292 | }, [definitions]); | ||
| 293 | |||
| 294 | const [expanded, setExpanded] = useState<Set<string>>(() => new Set()); | ||
| 295 | const [copied, setCopied] = useState(false); | ||
| 296 | |||
| 297 | const definitionMap = useMemo( | ||
| 298 | () => new Map(definitions.map((d) => [d.name, d.code])), | ||
| 299 | [definitions], | ||
| 300 | ); | ||
| 301 | |||
| 302 | const definitionNames = useMemo( | ||
| 303 | () => new Set(definitions.map((d) => d.name)), | ||
| 304 | [definitions], | ||
| 305 | ); | ||
| 306 | |||
| 307 | // Names that can be clicked — exclude trivial aliases (already inlined) | ||
| 308 | const clickableNames = useMemo(() => { | ||
| 309 | const names = new Set(definitionNames); | ||
| 310 | for (const name of trivialNames) names.delete(name); | ||
| 311 | return names; | ||
| 312 | }, [definitionNames, trivialNames]); | ||
| 313 | |||
| 314 | const emptyAncestors = useMemo(() => new Set<string>(), []); | ||
| 315 | |||
| 316 | // Merge user-expanded + trivial auto-expanded | ||
| 317 | const allExpanded = useMemo(() => { | ||
| 318 | const merged = new Set(expanded); | ||
| 319 | for (const name of trivialNames) merged.add(name); | ||
| 320 | return merged; | ||
| 321 | }, [expanded, trivialNames]); | ||
| 322 | |||
| 323 | // Build the display code: start with raw code, apply expansions, then format | ||
| 324 | const displayCode = useMemo(() => { | ||
| 325 | const withExpansions = applyExpansions(code, allExpanded, definitionMap, emptyAncestors); | ||
| 326 | return formatTypeScript(withExpansions); | ||
| 327 | }, [code, allExpanded, definitionMap, emptyAncestors]); | ||
| 328 | |||
| 329 | const tokens = useTokens(displayCode); | ||
| 330 | |||
| 331 | const handleToggle = useCallback((name: string) => { | ||
| 332 | setExpanded((prev) => { | ||
| 333 | const next = new Set(prev); | ||
| 334 | if (next.has(name)) { | ||
| 335 | next.delete(name); | ||
| 336 | } else { | ||
| 337 | next.add(name); | ||
| 338 | } | ||
| 339 | return next; | ||
| 340 | }); | ||
| 341 | }, []); | ||
| 342 | |||
| 343 | const handleCopy = useCallback(() => { | ||
| 344 | void navigator.clipboard.writeText(displayCode).then(() => { | ||
| 345 | setCopied(true); | ||
| 346 | setTimeout(() => setCopied(false), 1500); | ||
| 347 | }); | ||
| 348 | }, [displayCode]); | ||
| 349 | |||
| 350 | return ( | ||
| 351 | <div className={cn("rounded-lg border border-border/40 bg-card/60 overflow-hidden", className)}> | ||
| 352 | <div className="group relative"> | ||
| 353 | <button | ||
| 354 | type="button" | ||
| 355 | onClick={handleCopy} | ||
| 356 | className="absolute right-2 top-2 z-10 rounded-md border border-border bg-card/90 p-1.5 text-muted-foreground/40 opacity-0 backdrop-blur-sm hover:text-foreground group-hover:opacity-100 transition-opacity" | ||
| 357 | title="Copy to clipboard" | ||
| 358 | > | ||
| 359 | {copied ? <CheckIcon /> : <CopyIcon />} | ||
| 360 | </button> | ||
| 361 | |||
| 362 | <pre className="overflow-auto p-3 font-mono text-[0.75rem] leading-6 !bg-transparent"> | ||
| 363 | <code> | ||
| 364 | {tokens | ||
| 365 | ? ( | ||
| 366 | <HighlightedCode | ||
| 367 | tokens={tokens} | ||
| 368 | clickableNames={clickableNames} | ||
| 369 | onToggle={handleToggle} | ||
| 370 | expanded={allExpanded} | ||
| 371 | /> | ||
| 372 | ) | ||
| 373 | : <span className="text-foreground/60">{displayCode}</span> | ||
| 374 | } | ||
| 375 | </code> | ||
| 376 | </pre> | ||
| 377 | </div> | ||
| 378 | </div> | ||
| 379 | ); | ||
| 380 | } | ||
| @@ -0,0 +1,443 @@ | |||
| 1 | import { useState, useCallback } from "react"; | ||
| 2 | import { ChevronRight } from "lucide-react"; | ||
| 3 | |||
| 4 | // --------------------------------------------------------------------------- | ||
| 5 | // JSON Schema types (subset we render) | ||
| 6 | // --------------------------------------------------------------------------- | ||
| 7 | |||
| 8 | type JsonSchema = { | ||
| 9 | type?: string | string[]; | ||
| 10 | properties?: Record<string, JsonSchema>; | ||
| 11 | required?: string[]; | ||
| 12 | items?: JsonSchema | JsonSchema[]; | ||
| 13 | additionalProperties?: boolean | JsonSchema; | ||
| 14 | oneOf?: JsonSchema[]; | ||
| 15 | anyOf?: JsonSchema[]; | ||
| 16 | allOf?: JsonSchema[]; | ||
| 17 | enum?: unknown[]; | ||
| 18 | const?: unknown; | ||
| 19 | $ref?: string; | ||
| 20 | $defs?: Record<string, JsonSchema>; | ||
| 21 | description?: string; | ||
| 22 | title?: string; | ||
| 23 | default?: unknown; | ||
| 24 | nullable?: boolean; | ||
| 25 | format?: string; | ||
| 26 | }; | ||
| 27 | |||
| 28 | // --------------------------------------------------------------------------- | ||
| 29 | // Ref resolution — lazy, only on expand | ||
| 30 | // --------------------------------------------------------------------------- | ||
| 31 | |||
| 32 | const resolveRef = (ref: string, root: JsonSchema): JsonSchema | null => { | ||
| 33 | const name = ref.match(/^#\/\$defs\/(.+)$/)?.[1]; | ||
| 34 | if (!name || !root.$defs) return null; | ||
| 35 | return root.$defs[name] ?? null; | ||
| 36 | }; | ||
| 37 | |||
| 38 | const getRefName = (ref: string): string | undefined => | ||
| 39 | ref.match(/^#\/\$defs\/(.+)$/)?.[1]; | ||
| 40 | |||
| 41 | /** | ||
| 42 | * Fully resolve a schema, following $ref and unwrapping single-variant | ||
| 43 | * oneOf/anyOf so we can inspect the concrete shape. | ||
| 44 | */ | ||
| 45 | const deepResolve = (schema: JsonSchema, root: JsonSchema): JsonSchema => { | ||
| 46 | let s = schema; | ||
| 47 | if (s.$ref) { | ||
| 48 | const resolved = resolveRef(s.$ref, root); | ||
| 49 | if (resolved) s = resolved; | ||
| 50 | } | ||
| 51 | // Unwrap single-variant unions | ||
| 52 | if (s.oneOf?.length === 1) s = deepResolve(s.oneOf[0]!, root); | ||
| 53 | if (s.anyOf?.length === 1) s = deepResolve(s.anyOf[0]!, root); | ||
| 54 | return s; | ||
| 55 | }; | ||
| 56 | |||
| 57 | // --------------------------------------------------------------------------- | ||
| 58 | // Type label — human readable, shows ref names | ||
| 59 | // --------------------------------------------------------------------------- | ||
| 60 | |||
| 61 | const getTypeLabel = (schema: JsonSchema, root: JsonSchema): string => { | ||
| 62 | if (schema.$ref) { | ||
| 63 | return getRefName(schema.$ref) ?? "ref"; | ||
| 64 | } | ||
| 65 | |||
| 66 | if (schema.const !== undefined) return JSON.stringify(schema.const); | ||
| 67 | |||
| 68 | if (schema.enum) { | ||
| 69 | if (schema.enum.length <= 3) { | ||
| 70 | return schema.enum.map((v) => JSON.stringify(v)).join(" | "); | ||
| 71 | } | ||
| 72 | return `enum (${schema.enum.length})`; | ||
| 73 | } | ||
| 74 | |||
| 75 | if (schema.oneOf) { | ||
| 76 | if (schema.oneOf.length === 1) return getTypeLabel(schema.oneOf[0]!, root); | ||
| 77 | const labels = schema.oneOf.slice(0, 3).map((s) => getTypeLabel(s, root)); | ||
| 78 | if (schema.oneOf.length > 3) labels.push("…"); | ||
| 79 | return labels.join(" | "); | ||
| 80 | } | ||
| 81 | |||
| 82 | if (schema.anyOf) { | ||
| 83 | if (schema.anyOf.length === 1) return getTypeLabel(schema.anyOf[0]!, root); | ||
| 84 | const labels = schema.anyOf.slice(0, 3).map((s) => getTypeLabel(s, root)); | ||
| 85 | if (schema.anyOf.length > 3) labels.push("…"); | ||
| 86 | return labels.join(" | "); | ||
| 87 | } | ||
| 88 | |||
| 89 | if (schema.allOf) { | ||
| 90 | // If any allOf member is a named ref, show that | ||
| 91 | for (const s of schema.allOf) { | ||
| 92 | if (s.$ref) { | ||
| 93 | const name = getRefName(s.$ref); | ||
| 94 | if (name) return name; | ||
| 95 | } | ||
| 96 | } | ||
| 97 | return "object"; | ||
| 98 | } | ||
| 99 | |||
| 100 | const types = Array.isArray(schema.type) | ||
| 101 | ? schema.type | ||
| 102 | : schema.type | ||
| 103 | ? [schema.type] | ||
| 104 | : []; | ||
| 105 | |||
| 106 | if (types.includes("array")) { | ||
| 107 | if (schema.items && !Array.isArray(schema.items)) { | ||
| 108 | return `${getTypeLabel(schema.items, root)}[]`; | ||
| 109 | } | ||
| 110 | return "array"; | ||
| 111 | } | ||
| 112 | |||
| 113 | if (types.includes("object")) { | ||
| 114 | if (schema.additionalProperties && typeof schema.additionalProperties === "object") { | ||
| 115 | return `Record<string, ${getTypeLabel(schema.additionalProperties, root)}>`; | ||
| 116 | } | ||
| 117 | return "object"; | ||
| 118 | } | ||
| 119 | |||
| 120 | if (types.length === 1) { | ||
| 121 | const t = types[0]!; | ||
| 122 | if (schema.format) return `${t}<${schema.format}>`; | ||
| 123 | return t; | ||
| 124 | } | ||
| 125 | if (types.length > 1) return types.join(" | "); | ||
| 126 | |||
| 127 | return "any"; | ||
| 128 | }; | ||
| 129 | |||
| 130 | // --------------------------------------------------------------------------- | ||
| 131 | // Expandability check — conservative, resolves deeply | ||
| 132 | // --------------------------------------------------------------------------- | ||
| 133 | |||
| 134 | const getChildCount = (schema: JsonSchema, root: JsonSchema): number => { | ||
| 135 | const s = deepResolve(schema, root); | ||
| 136 | |||
| 137 | if (s.properties) return Object.keys(s.properties).length; | ||
| 138 | |||
| 139 | if (s.items && !Array.isArray(s.items)) { | ||
| 140 | const itemResolved = deepResolve(s.items, root); | ||
| 141 | if (itemResolved.properties) return Object.keys(itemResolved.properties).length; | ||
| 142 | return 0; | ||
| 143 | } | ||
| 144 | |||
| 145 | if (s.allOf) { | ||
| 146 | const merged = mergeAllOf(s.allOf, root); | ||
| 147 | if (merged.properties) return Object.keys(merged.properties).length; | ||
| 148 | return 0; | ||
| 149 | } | ||
| 150 | |||
| 151 | if (s.oneOf && s.oneOf.length > 1) return s.oneOf.length; | ||
| 152 | if (s.anyOf && s.anyOf.length > 1) return s.anyOf.length; | ||
| 153 | |||
| 154 | if (s.additionalProperties && typeof s.additionalProperties === "object") return 1; | ||
| 155 | |||
| 156 | return 0; | ||
| 157 | }; | ||
| 158 | |||
| 159 | const isExpandable = (schema: JsonSchema, root: JsonSchema): boolean => | ||
| 160 | getChildCount(schema, root) > 0; | ||
| 161 | |||
| 162 | // --------------------------------------------------------------------------- | ||
| 163 | // Merge allOf | ||
| 164 | // --------------------------------------------------------------------------- | ||
| 165 | |||
| 166 | const mergeAllOf = (schemas: JsonSchema[], root: JsonSchema): JsonSchema => { | ||
| 167 | const merged: JsonSchema = { type: "object", properties: {}, required: [] }; | ||
| 168 | for (const s of schemas) { | ||
| 169 | const resolved = deepResolve(s, root); | ||
| 170 | if (resolved.properties) { | ||
| 171 | merged.properties = { ...merged.properties, ...resolved.properties }; | ||
| 172 | } | ||
| 173 | if (resolved.required) { | ||
| 174 | merged.required = [...(merged.required ?? []), ...resolved.required]; | ||
| 175 | } | ||
| 176 | if (resolved.description && !merged.description) { | ||
| 177 | merged.description = resolved.description; | ||
| 178 | } | ||
| 179 | } | ||
| 180 | return merged; | ||
| 181 | }; | ||
| 182 | |||
| 183 | // --------------------------------------------------------------------------- | ||
| 184 | // Type label styling — plain text, no colored pills | ||
| 185 | // --------------------------------------------------------------------------- | ||
| 186 | |||
| 187 | const typeClasses = "font-mono text-[0.6875rem] leading-5 text-muted-foreground/50"; | ||
| 188 | |||
| 189 | // --------------------------------------------------------------------------- | ||
| 190 | // PropertyRow | ||
| 191 | // --------------------------------------------------------------------------- | ||
| 192 | |||
| 193 | function PropertyRow(props: { | ||
| 194 | name: string; | ||
| 195 | schema: JsonSchema; | ||
| 196 | root: JsonSchema; | ||
| 197 | required: boolean; | ||
| 198 | depth: number; | ||
| 199 | isLast?: boolean; | ||
| 200 | /** Hide the required/optional badge entirely (e.g. for union variants) */ | ||
| 201 | hideRequiredBadge?: boolean; | ||
| 202 | }) { | ||
| 203 | const { name, schema, root, required, depth, hideRequiredBadge } = props; | ||
| 204 | const [open, setOpen] = useState(false); | ||
| 205 | const [resolved, setResolved] = useState<JsonSchema | null>(null); | ||
| 206 | |||
| 207 | const expandable = isExpandable(schema, root); | ||
| 208 | const typeLabel = getTypeLabel(schema, root); | ||
| 209 | const description = schema.description | ||
| 210 | ?? (schema.$ref ? (resolveRef(schema.$ref, root)?.description) : undefined); | ||
| 211 | |||
| 212 | const handleToggle = useCallback(() => { | ||
| 213 | if (!open && !resolved && schema.$ref) { | ||
| 214 | setResolved(resolveRef(schema.$ref, root)); | ||
| 215 | } | ||
| 216 | setOpen((v) => !v); | ||
| 217 | }, [open, resolved, schema, root]); | ||
| 218 | |||
| 219 | const childSchema = schema.$ref | ||
| 220 | ? resolved ?? resolveRef(schema.$ref, root) ?? schema | ||
| 221 | : schema; | ||
| 222 | |||
| 223 | return ( | ||
| 224 | <div> | ||
| 225 | <div | ||
| 226 | role={expandable ? "button" : undefined} | ||
| 227 | tabIndex={expandable ? 0 : undefined} | ||
| 228 | onClick={expandable ? handleToggle : undefined} | ||
| 229 | onKeyDown={ | ||
| 230 | expandable | ||
| 231 | ? (e) => { | ||
| 232 | if (e.key === "Enter" || e.key === " ") { | ||
| 233 | e.preventDefault(); | ||
| 234 | handleToggle(); | ||
| 235 | } | ||
| 236 | } | ||
| 237 | : undefined | ||
| 238 | } | ||
| 239 | className={[ | ||
| 240 | "flex items-start gap-2 py-2.5 px-3", | ||
| 241 | expandable | ||
| 242 | ? "cursor-pointer hover:bg-accent/30 transition-colors" | ||
| 243 | : "", | ||
| 244 | ].join(" ")} | ||
| 245 | style={depth > 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined} | ||
| 246 | > | ||
| 247 | {/* Chevron or dot */} | ||
| 248 | <div className="mt-0.5 flex size-4 shrink-0 items-center justify-center"> | ||
| 249 | {expandable ? ( | ||
| 250 | <ChevronRight | ||
| 251 | className="size-3 shrink-0 text-muted-foreground/30 transition-transform duration-150" | ||
| 252 | style={open ? { transform: "rotate(90deg)" } : undefined} | ||
| 253 | /> | ||
| 254 | ) : ( | ||
| 255 | <span className="size-1 rounded-full bg-muted-foreground/15" /> | ||
| 256 | )} | ||
| 257 | </div> | ||
| 258 | |||
| 259 | {/* Content */} | ||
| 260 | <div className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-0.5"> | ||
| 261 | <p className="font-medium text-foreground text-sm">{name}</p> | ||
| 262 | <p className={typeClasses}>{typeLabel}</p> | ||
| 263 | {!hideRequiredBadge && ( | ||
| 264 | required ? ( | ||
| 265 | <p className="text-[0.6875rem] leading-5 text-orange-600/60 dark:text-orange-400/60"> | ||
| 266 | required | ||
| 267 | </p> | ||
| 268 | ) : ( | ||
| 269 | <p className="text-[0.6875rem] leading-5 text-muted-foreground/25"> | ||
| 270 | optional | ||
| 271 | </p> | ||
| 272 | ) | ||
| 273 | )} | ||
| 274 | {schema.default !== undefined && ( | ||
| 275 | <p className="text-[0.6875rem] leading-5 text-muted-foreground/30"> | ||
| 276 | = {JSON.stringify(schema.default)} | ||
| 277 | </p> | ||
| 278 | )} | ||
| 279 | </div> | ||
| 280 | </div> | ||
| 281 | |||
| 282 | {/* Description — below the row */} | ||
| 283 | {description && ( | ||
| 284 | <p | ||
| 285 | className="px-3 pb-2 text-[0.8125rem] leading-5 text-muted-foreground/50" | ||
| 286 | style={{ paddingLeft: `${(depth * 16) + 32}px` }} | ||
| 287 | > | ||
| 288 | {description} | ||
| 289 | </p> | ||
| 290 | )} | ||
| 291 | |||
| 292 | {/* Children — rendered lazily on expand */} | ||
| 293 | {open && expandable && ( | ||
| 294 | <div | ||
| 295 | className="border-l border-border/30" | ||
| 296 | style={{ marginLeft: `${depth * 16 + 20}px` }} | ||
| 297 | > | ||
| 298 | <PropertyChildren schema={childSchema} root={root} depth={depth + 1} /> | ||
| 299 | </div> | ||
| 300 | )} | ||
| 301 | </div> | ||
| 302 | ); | ||
| 303 | } | ||
| 304 | |||
| 305 | // --------------------------------------------------------------------------- | ||
| 306 | // PropertyChildren — renders sub-properties | ||
| 307 | // --------------------------------------------------------------------------- | ||
| 308 | |||
| 309 | function PropertyChildren(props: { | ||
| 310 | schema: JsonSchema; | ||
| 311 | root: JsonSchema; | ||
| 312 | depth: number; | ||
| 313 | }) { | ||
| 314 | const { schema: rawSchema, root, depth } = props; | ||
| 315 | |||
| 316 | if (depth > 6) { | ||
| 317 | return ( | ||
| 318 | <p className="px-3 py-2 text-[0.8125rem] text-muted-foreground/30"> | ||
| 319 | Nested too deep to display. | ||
| 320 | </p> | ||
| 321 | ); | ||
| 322 | } | ||
| 323 | |||
| 324 | const schema = deepResolve(rawSchema, root); | ||
| 325 | const required = new Set(schema.required ?? []); | ||
| 326 | |||
| 327 | // Object properties | ||
| 328 | if (schema.properties && Object.keys(schema.properties).length > 0) { | ||
| 329 | const entries = Object.entries(schema.properties); | ||
| 330 | entries.sort(([a], [b]) => { | ||
| 331 | const ar = required.has(a); | ||
| 332 | const br = required.has(b); | ||
| 333 | if (ar !== br) return ar ? -1 : 1; | ||
| 334 | return a.localeCompare(b); | ||
| 335 | }); | ||
| 336 | return ( | ||
| 337 | <div className="divide-y divide-border/20"> | ||
| 338 | {entries.map(([key, value], i) => ( | ||
| 339 | <PropertyRow | ||
| 340 | key={key} | ||
| 341 | name={key} | ||
| 342 | schema={value} | ||
| 343 | root={root} | ||
| 344 | required={required.has(key)} | ||
| 345 | depth={depth} | ||
| 346 | isLast={i === entries.length - 1} | ||
| 347 | /> | ||
| 348 | ))} | ||
| 349 | </div> | ||
| 350 | ); | ||
| 351 | } | ||
| 352 | |||
| 353 | // Array items | ||
| 354 | if (schema.items && !Array.isArray(schema.items)) { | ||
| 355 | const itemSchema = deepResolve(schema.items, root); | ||
| 356 | if (itemSchema.properties && Object.keys(itemSchema.properties).length > 0) { | ||
| 357 | return <PropertyChildren schema={itemSchema} root={root} depth={depth} />; | ||
| 358 | } | ||
| 359 | return ( | ||
| 360 | <PropertyRow name="items" schema={schema.items} root={root} required depth={depth} /> | ||
| 361 | ); | ||
| 362 | } | ||
| 363 | |||
| 364 | // allOf | ||
| 365 | if (schema.allOf) { | ||
| 366 | const merged = mergeAllOf(schema.allOf, root); | ||
| 367 | if (merged.properties && Object.keys(merged.properties).length > 0) { | ||
| 368 | return <PropertyChildren schema={merged} root={root} depth={depth} />; | ||
| 369 | } | ||
| 370 | } | ||
| 371 | |||
| 372 | // oneOf / anyOf (only if multiple) — these are choices, not required fields | ||
| 373 | const variants = schema.oneOf ?? schema.anyOf; | ||
| 374 | if (variants && variants.length > 1) { | ||
| 375 | const label = schema.oneOf ? "One of" : "Any of"; | ||
| 376 | return ( | ||
| 377 | <div> | ||
| 378 | <p | ||
| 379 | className="px-3 py-2 text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/30" | ||
| 380 | style={depth > 0 ? { paddingLeft: `${depth * 16 + 12}px` } : undefined} | ||
| 381 | > | ||
| 382 | {label} | ||
| 383 | </p> | ||
| 384 | <div className="divide-y divide-border/20"> | ||
| 385 | {variants.map((variant, i) => ( | ||
| 386 | <PropertyRow | ||
| 387 | key={i} | ||
| 388 | name={variant.title ?? `option ${i + 1}`} | ||
| 389 | schema={variant} | ||
| 390 | root={root} | ||
| 391 | required={false} | ||
| 392 | depth={depth} | ||
| 393 | hideRequiredBadge | ||
| 394 | /> | ||
| 395 | ))} | ||
| 396 | </div> | ||
| 397 | </div> | ||
| 398 | ); | ||
| 399 | } | ||
| 400 | |||
| 401 | // Record / additionalProperties | ||
| 402 | if (schema.additionalProperties && typeof schema.additionalProperties === "object") { | ||
| 403 | return ( | ||
| 404 | <PropertyRow | ||
| 405 | name="[key]" | ||
| 406 | schema={schema.additionalProperties} | ||
| 407 | root={root} | ||
| 408 | required | ||
| 409 | depth={depth} | ||
| 410 | /> | ||
| 411 | ); | ||
| 412 | } | ||
| 413 | |||
| 414 | return null; | ||
| 415 | } | ||
| 416 | |||
| 417 | // --------------------------------------------------------------------------- | ||
| 418 | // SchemaExplorer — main export | ||
| 419 | // --------------------------------------------------------------------------- | ||
| 420 | |||
| 421 | export function SchemaExplorer(props: { schema: unknown }) { | ||
| 422 | const schema = props.schema as JsonSchema | undefined; | ||
| 423 | if (!schema) return null; | ||
| 424 | |||
| 425 | const hasContent = isExpandable(schema, schema); | ||
| 426 | |||
| 427 | if (!hasContent) { | ||
| 428 | const typeLabel = getTypeLabel(schema, schema); | ||
| 429 | return ( | ||
| 430 | <div className="rounded-lg border border-border/40 px-4 py-3"> | ||
| 431 | <p className="text-sm text-muted-foreground/50"> | ||
| 432 | <span className={typeClasses}>{typeLabel}</span> | ||
| 433 | </p> | ||
| 434 | </div> | ||
| 435 | ); | ||
| 436 | } | ||
| 437 | |||
| 438 | return ( | ||
| 439 | <div className="rounded-lg border border-border/40 overflow-hidden divide-y divide-border/20"> | ||
| 440 | <PropertyChildren schema={schema} root={schema} depth={0} /> | ||
| 441 | </div> | ||
| 442 | ); | ||
| 443 | } | ||
| @@ -2,10 +2,10 @@ import { useMemo, useState } from "react"; · 1 unmodified line | |||
| 2 | import { useAtomValue, Result } from "@effect-atom/atom-react"; | 2 | import { useAtomValue, Result } from "@effect-atom/atom-react"; |
| 3 | import { toolSchemaAtom } from "../api/atoms"; | 3 | import { toolSchemaAtom } from "../api/atoms"; |
| 4 | import { ScopeId, ToolId } from "@executor/sdk"; | 4 | import { ScopeId, ToolId } from "@executor/sdk"; |
| 5 | import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./accordion"; | ||
| 6 | import { Badge } from "./badge"; | ||
| 7 | import { CodeBlock } from "./code-block"; | ||
| 8 | import { Markdown } from "./markdown"; | 5 | import { Markdown } from "./markdown"; |
| 6 | import { SchemaExplorer } from "./schema-explorer"; | ||
| 7 | import { ExpandableCodeBlock } from "./expandable-code-block"; | ||
| 8 | import { Copy, Check, ChevronRight } from "lucide-react"; | ||
| 9 | 9 | ||
| 10 | // --------------------------------------------------------------------------- | 10 | // --------------------------------------------------------------------------- |
| 11 | // Copy button | 11 | // Copy button |
| @@ -27,48 +27,32 @@ function CopyButton(props: { text: string; label?: string }) { · 15 unmodified lines | |||
| 27 | title={props.label ?? "Copy"} | 27 | title={props.label ?? "Copy"} |
| 28 | > | 28 | > |
| 29 | {copied ? ( | 29 | {copied ? ( |
| 30 | <svg viewBox="0 0 16 16" className="size-3.5"> | 30 | <Check className="size-3.5 shrink-0" /> |
| 31 | <path d="M3 8l3 3 7-7" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /> | ||
| 32 | </svg> | ||
| 33 | ) : ( | 31 | ) : ( |
| 34 | <svg viewBox="0 0 16 16" className="size-3.5"> | 32 | <Copy className="size-3.5 shrink-0" /> |
| 35 | <rect x="5" y="5" width="8" height="8" rx="1" fill="none" stroke="currentColor" strokeWidth="1.2" /> | ||
| 36 | <path d="M3 11V3h8" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> | ||
| 37 | </svg> | ||
| 38 | )} | 33 | )} |
| 39 | </button> | 34 | </button> |
| 40 | ); | 35 | ); |
| 41 | } | 36 | } |
| 42 | 37 | ||
| 43 | // --------------------------------------------------------------------------- | 38 | // --------------------------------------------------------------------------- |
| 44 | // Build type declarations from tool TypeScript previews | 39 | // Helpers |
| 45 | // --------------------------------------------------------------------------- | 40 | // --------------------------------------------------------------------------- |
| 46 | 41 | ||
| 47 | const buildCallSignature = ( | 42 | const friendlyName = (name: string): string => { |
| 48 | toolName: string, | 43 | const leaf = name.split(".").pop() ?? name; |
| 49 | inputTypeScript?: string, | 44 | return leaf |
| 50 | outputTypeScript?: string, | 45 | .replace(/([a-z])([A-Z])/g, "$1 $2") |
| 51 | ): string => { | 46 | .replace(/[_.-]/g, " ") |
| 52 | const inputType = inputTypeScript ?? "void"; | 47 | .replace(/\b\w/g, (c) => c.toUpperCase()); |
| 53 | const outputType = outputTypeScript ?? "void"; | ||
| 54 | |||
| 55 | const leaf = toolName.split(".").pop() ?? toolName; | ||
| 56 | return `declare function ${leaf}(input: ${inputType}): ${outputType}`; | ||
| 57 | }; | 48 | }; |
| 58 | 49 | ||
| 59 | const buildPrimaryContract = (input: { | 50 | const breadcrumbParts = (name: string): string[] => |
| 60 | toolName: string; | 51 | name.split(".").map((p) => |
| 61 | inputTypeScript?: string; | 52 | p.replace(/([a-z])([A-Z])/g, "$1 $2") |
| 62 | outputTypeScript?: string; | 53 | .replace(/[_-]/g, " ") |
| 63 | }): string => { | 54 | .replace(/\b\w/g, (c) => c.toUpperCase()), |
| 64 | const sections = [ | 55 | ); |
| 65 | buildCallSignature(input.toolName, input.inputTypeScript, input.outputTypeScript), | ||
| 66 | input.inputTypeScript ? `type Input = ${input.inputTypeScript}` : "type Input = void", | ||
| 67 | input.outputTypeScript ? `type Output = ${input.outputTypeScript}` : "type Output = void", | ||
| 68 | ]; | ||
| 69 | |||
| 70 | return sections.join("\n\n"); | ||
| 71 | }; | ||
| 72 | 56 | ||
| 73 | // --------------------------------------------------------------------------- | 57 | // --------------------------------------------------------------------------- |
| 74 | // ToolDetail | 58 | // ToolDetail |
| @@ -83,162 +67,166 @@ export function ToolDetail(props: { · 8 unmodified lines | |||
| 83 | const toolContract = useAtomValue( | 67 | const toolContract = useAtomValue( |
| 84 | toolSchemaAtom(props.scopeId, props.toolId as ToolId), | 68 | toolSchemaAtom(props.scopeId, props.toolId as ToolId), |
| 85 | ); | 69 | ); |
| 70 | const [tab, setTab] = useState<"schema" | "typescript">("schema"); | ||
| 86 | 71 | ||
| 87 | const types = useMemo(() => { | 72 | const data = useMemo(() => { |
| 88 | if (!Result.isSuccess(toolContract)) return null; | 73 | if (!Result.isSuccess(toolContract)) return null; |
| 89 | const v = toolContract.value; | 74 | const v = toolContract.value; |
| 90 | const definitions = Object.entries(v.typeScriptDefinitions ?? {}).map(([name, body]) => ({ | 75 | const definitions = Object.entries(v.typeScriptDefinitions ?? {}).map(([name, body]) => ({ |
| 91 | name, | 76 | name, |
| 92 | code: `type ${name} = ${body}`, | 77 | code: body, |
| 93 | })); | 78 | })); |
| 94 | 79 | ||
| 95 | return { | 80 | return { |
| 96 | inputTypeScript: v.inputTypeScript, | 81 | inputSchema: v.inputSchema, |
| 97 | outputTypeScript: v.outputTypeScript, | 82 | outputSchema: v.outputSchema, |
| 98 | contract: buildPrimaryContract({ | 83 | inputTypeScript: v.inputTypeScript |
| 99 | toolName: props.toolName, | 84 | ? `type Input = ${v.inputTypeScript}` |
| 100 | inputTypeScript: v.inputTypeScript, | 85 | : null, |
| 101 | outputTypeScript: v.outputTypeScript, | 86 | outputTypeScript: v.outputTypeScript |
| 102 | }), | 87 | ? `type Output = ${v.outputTypeScript}` |
| 88 | : null, | ||
| 103 | definitions, | 89 | definitions, |
| 104 | }; | 90 | }; |
| 105 | }, [toolContract, props.toolName]); | 91 | }, [toolContract, props.toolName]); |
| 106 | 92 | ||
| 107 | const namespace = props.toolName.split(".").slice(0, -1).join(".") || "global"; | 93 | const crumbs = breadcrumbParts(props.toolName); |
| 94 | const displayName = friendlyName(props.toolName); | ||
| 108 | 95 | ||
| 109 | return ( | 96 | return ( |
| 110 | <div className="flex h-full flex-col overflow-hidden bg-[radial-gradient(circle_at_top,rgba(120,119,198,0.08),transparent_32%),linear-gradient(to_bottom,rgba(255,255,255,0.02),transparent_24%)]"> | 97 | <div className="flex h-full flex-col overflow-hidden"> |
| 111 | <div className="shrink-0 border-b border-border/70 bg-background/90 backdrop-blur-xl"> | 98 | {/* Header + tabs */} |
| 112 | <div className="px-5 py-4"> | 99 | <div className="shrink-0 border-b border-border/40"> |
| 113 | <div className="flex items-start gap-4"> | 100 | <div className="px-5 pt-4 pb-0"> |
| 114 | <div className="relative mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary shadow-[0_10px_30px_rgba(120,119,198,0.12)]"> | 101 | {crumbs.length > 1 && ( |
| 115 | <div className="absolute inset-0 rounded-2xl bg-[linear-gradient(135deg,rgba(255,255,255,0.14),transparent_55%)]" /> | 102 | <div className="flex items-center gap-1 text-sm text-muted-foreground/40"> |
| 116 | <svg viewBox="0 0 16 16" className="relative size-4"> | 103 | {crumbs.slice(0, -1).map((part, i) => ( |
| 117 | <path | 104 | <span key={i} className="flex items-center gap-1"> |
| 118 | d="M4 2h8l1 3H3l1-3zM3 6h10v8H3V6z" | 105 | {i > 0 && <ChevronRight className="size-3 shrink-0" />} |
| 119 | fill="none" | 106 | <span>{part}</span> |
| 120 | stroke="currentColor" | 107 | </span> |
| 121 | strokeWidth="1.2" | 108 | ))} |
| 122 | /> | ||
| 123 | </svg> | ||
| 124 | </div> | 109 | </div> |
| 125 | <div className="min-w-0 flex-1"> | 110 | )} |
| 126 | <div className="flex flex-wrap items-center gap-2"> | 111 | <div className="mt-1 flex items-center gap-2"> |
| 127 | <Badge variant="outline" className="rounded-full border-primary/20 bg-primary/5 px-2.5 py-1 font-mono text-[10px] tracking-[0.18em] text-primary/80 uppercase"> | 112 | <h3 className="text-base font-semibold text-foreground truncate"> |
| 128 | {namespace} | 113 | {displayName} |
| 129 | </Badge> | 114 | </h3> |
| 130 | <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.18em] text-muted-foreground/80"> | 115 | <CopyButton text={props.toolId} label="Copy tool ID" /> |
| 131 | {types?.definitions.length ?? 0} shared type{(types?.definitions.length ?? 0) === 1 ? "" : "s"} | 116 | </div> |
| 132 | </Badge> | 117 | {props.toolDescription && ( |
| 133 | </div> | 118 | <div className="mt-1.5 max-w-lg text-sm text-muted-foreground/70 line-clamp-2"> |
| 134 | <div className="mt-2 flex items-start gap-2"> | 119 | <Markdown>{props.toolDescription}</Markdown> |
| 135 | <h3 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground font-mono leading-6"> | ||
| 136 | {props.toolName} | ||
| 137 | </h3> | ||
| 138 | <CopyButton text={props.toolId} label="Copy tool ID" /> | ||
| 139 | </div> | ||
| 140 | {props.toolDescription && ( | ||
| 141 | <div className="mt-2 max-w-2xl text-sm text-muted-foreground/90"> | ||
| 142 | <Markdown>{props.toolDescription}</Markdown> | ||
| 143 | </div> | ||
| 144 | )} | ||
| 145 | </div> | 120 | </div> |
| 121 | )} | ||
| 122 | |||
| 123 | {/* Tabs */} | ||
| 124 | <div className="mt-3 flex gap-4" role="tablist"> | ||
| 125 | <button | ||
| 126 | type="button" | ||
| 127 | role="tab" | ||
| 128 | aria-selected={tab === "schema"} | ||
| 129 | onClick={() => setTab("schema")} | ||
| 130 | className={[ | ||
| 131 | "border-b-2 pb-2.5 text-sm font-medium transition-colors", | ||
| 132 | tab === "schema" | ||
| 133 | ? "border-primary text-foreground" | ||
| 134 | : "border-transparent text-muted-foreground/50 hover:text-muted-foreground", | ||
| 135 | ].join(" ")} | ||
| 136 | > | ||
| 137 | Schema | ||
| 138 | </button> | ||
| 139 | <button | ||
| 140 | type="button" | ||
| 141 | role="tab" | ||
| 142 | aria-selected={tab === "typescript"} | ||
| 143 | onClick={() => setTab("typescript")} | ||
| 144 | className={[ | ||
| 145 | "border-b-2 pb-2.5 text-sm font-medium transition-colors", | ||
| 146 | tab === "typescript" | ||
| 147 | ? "border-primary text-foreground" | ||
| 148 | : "border-transparent text-muted-foreground/50 hover:text-muted-foreground", | ||
| 149 | ].join(" ")} | ||
| 150 | > | ||
| 151 | TypeScript | ||
| 152 | </button> | ||
| 146 | </div> | 153 | </div> |
| 147 | </div> | 154 | </div> |
| 148 | </div> | 155 | </div> |
| 149 | 156 | ||
| 157 | {/* Content */} | ||
| 150 | <div className="flex-1 overflow-y-auto"> | 158 | <div className="flex-1 overflow-y-auto"> |
| 151 | {Result.match(toolContract, { | 159 | {Result.match(toolContract, { |
| 152 | onInitial: () => ( | 160 | onInitial: () => ( |
| 153 | <div className="p-5 text-sm text-muted-foreground">Loading tool contract…</div> | 161 | <div className="p-5 text-sm text-muted-foreground">Loading…</div> |
| 154 | ), | 162 | ), |
| 155 | onFailure: () => ( | 163 | onFailure: () => ( |
| 156 | <div className="p-5 text-sm text-destructive">Failed to load tool contract</div> | 164 | <div className="p-5 text-sm text-destructive">Something went wrong</div> |
| 157 | ), | 165 | ), |
| 158 | onSuccess: () => ( | 166 | onSuccess: () => |
| 159 | <div className="space-y-4 px-5 py-4"> | 167 | tab === "schema" ? ( |
| 160 | <div className="rounded-2xl border border-border/70 bg-card/70 shadow-[0_12px_40px_rgba(0,0,0,0.04)] overflow-hidden"> | 168 | <div className="px-5 py-5 space-y-6"> |
| 161 | <div className="flex items-center justify-between border-b border-border/70 bg-muted/30 px-4 py-3"> | 169 | <section> |
| 162 | <div> | 170 | <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> |
| 163 | <p className="text-[11px] font-medium uppercase tracking-[0.22em] text-muted-foreground/75"> | 171 | Parameters |
| 164 | Primary contract | 172 | </h4> |
| 165 | </p> | 173 | <div className="mt-2.5"> |
| 166 | <p className="mt-1 text-xs text-muted-foreground/80"> | 174 | {data?.inputSchema ? ( |
| 167 | One compact view for the call signature, input, and output. | 175 | <SchemaExplorer schema={data.inputSchema} /> |
| 168 | </p> | 176 | ) : ( |
| 177 | <p className="py-2 text-sm text-muted-foreground/40">None</p> | ||
| 178 | )} | ||
| 169 | </div> | 179 | </div> |
| 170 | <div className="hidden items-center gap-2 sm:flex"> | 180 | </section> |
| 171 | <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.16em]"> | 181 | |
| 172 | {types?.inputTypeScript ? "input" : "no input"} | 182 | <section> |
| 173 | </Badge> | 183 | <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> |
| 174 | <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.16em]"> | 184 | Response |
| 175 | {types?.outputTypeScript ? "output" : "no output"} | 185 | </h4> |
| 176 | </Badge> | 186 | <div className="mt-2.5"> |
| 187 | {data?.outputSchema ? ( | ||
| 188 | <SchemaExplorer schema={data.outputSchema} /> | ||
| 189 | ) : ( | ||
| 190 | <p className="py-2 text-sm text-muted-foreground/40">None</p> | ||
| 191 | )} | ||
| 177 | </div> | 192 | </div> |
| 178 | </div> | 193 | </section> |
| 179 | <div className="p-4"> | ||
| 180 | {types?.contract && ( | ||
| 181 | <CodeBlock | ||
| 182 | code={types.contract} | ||
| 183 | lang="typescript" | ||
| 184 | className="rounded-xl border-border/60 bg-background/70" | ||
| 185 | /> | ||
| 186 | )} | ||
| 187 | </div> | ||
| 188 | </div> | 194 | </div> |
| 189 | 195 | ) : ( | |
| 190 | <div className="rounded-2xl border border-border/70 bg-card/50 px-4 py-3 shadow-[0_8px_30px_rgba(0,0,0,0.03)]"> | 196 | <div className="px-5 py-5 space-y-6"> |
| 191 | <div className="flex items-center justify-between gap-3"> | 197 | <section> |
| 192 | <div> | 198 | <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> |
| 193 | <p className="text-[11px] font-medium uppercase tracking-[0.22em] text-muted-foreground/75"> | 199 | Input |
| 194 | Shared definitions | 200 | </h4> |
| 195 | </p> | 201 | <div className="mt-2.5"> |
| 196 | <p className="mt-1 text-xs text-muted-foreground/80"> | 202 | {data?.inputTypeScript ? ( |
| 197 | Referenced types stay tucked away until you need them. | 203 | <ExpandableCodeBlock |
| 198 | </p> | 204 | code={data.inputTypeScript} |
| 205 | definitions={data.definitions} | ||
| 206 | /> | ||
| 207 | ) : ( | ||
| 208 | <p className="py-2 text-sm text-muted-foreground/40">void</p> | ||
| 209 | )} | ||
| 199 | </div> | 210 | </div> |
| 200 | <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.16em]"> | 211 | </section> |
| 201 | {types?.definitions.length ?? 0} | 212 | |
| 202 | </Badge> | 213 | <section> |
| 203 | </div> | 214 | <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> |
| 204 | 215 | Output | |
| 205 | {types && types.definitions.length > 0 ? ( | 216 | </h4> |
| 206 | <Accordion type="multiple" className="mt-3 divide-y divide-border/60"> | 217 | <div className="mt-2.5"> |
| 207 | {types.definitions.map((definition) => ( | 218 | {data?.outputTypeScript ? ( |
| 208 | <AccordionItem key={definition.name} value={definition.name} className="border-none"> | 219 | <ExpandableCodeBlock |
| 209 | <AccordionTrigger className="py-3 hover:no-underline"> | 220 | code={data.outputTypeScript} |
| 210 | <div className="flex min-w-0 items-center gap-3"> | 221 | definitions={data.definitions} |
| 211 | <div className="flex size-8 shrink-0 items-center justify-center rounded-xl border border-border/70 bg-background/80 text-[11px] font-medium text-muted-foreground"> | 222 | /> |
| 212 | TS | 223 | ) : ( |
| 213 | </div> | 224 | <p className="py-2 text-sm text-muted-foreground/40">void</p> |
| 214 | <div className="min-w-0"> | 225 | )} |
| 215 | <div className="truncate font-mono text-sm text-foreground"> | ||
| 216 | {definition.name} | ||
| 217 | </div> | ||
| 218 | <div className="text-xs text-muted-foreground/75"> | ||
| 219 | Referenced by the primary contract | ||
| 220 | </div> | ||
| 221 | </div> | ||
| 222 | </div> | ||
| 223 | </AccordionTrigger> | ||
| 224 | <AccordionContent className="pb-1"> | ||
| 225 | <CodeBlock | ||
| 226 | code={definition.code} | ||
| 227 | lang="typescript" | ||
| 228 | className="rounded-xl border-border/60 bg-background/70" | ||
| 229 | /> | ||
| 230 | </AccordionContent> | ||
| 231 | </AccordionItem> | ||
| 232 | ))} | ||
| 233 | </Accordion> | ||
| 234 | ) : ( | ||
| 235 | <div className="mt-3 rounded-xl border border-dashed border-border/70 bg-background/40 px-4 py-5 text-center text-[13px] text-muted-foreground/65"> | ||
| 236 | This contract is self-contained — no extra referenced types. | ||
| 237 | </div> | 226 | </div> |
| 238 | )} | 227 | </section> |
| 239 | </div> | 228 | </div> |
| 240 | </div> | 229 | ), |
| 241 | ), | ||
| 242 | })} | 230 | })} |
| 243 | </div> | 231 | </div> |
| 244 | </div> | 232 | </div> |
| @@ -257,12 +245,8 @@ export function ToolDetailEmpty(props: { hasTools: boolean }) { · 12 unmodified lines | |||
| 257 | {props.hasTools ? "Select a tool" : "No tools available"} | 245 | {props.hasTools ? "Select a tool" : "No tools available"} |
| 258 | </p> | 246 | </p> |
| 259 | {props.hasTools && ( | 247 | {props.hasTools && ( |
| 260 | <p className="mt-1 text-xs text-muted-foreground"> | 248 | <p className="mt-1.5 text-sm text-muted-foreground/50"> |
| 261 | Choose from the list or press{" "} | 249 | Choose from the list to see what it does. |
| 262 | <kbd className="rounded border border-border bg-muted px-1 py-px text-[10px]"> | ||
| 263 | / | ||
| 264 | </kbd>{" "} | ||
| 265 | to search. | ||
| 266 | </p> | 250 | </p> |
| 267 | )} | 251 | )} |
| 268 | </div> | 252 | </div> |