Redesign tools detail view with interactive schema explorer
Unified · Split
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | ||
| 7 | 7 | HttpApiBuilder, |
| 8 | 8 | HttpApiSwagger, |
| 9 | 9 | HttpMiddleware, |
| 10 | + HttpRouter, | |
| 10 | 11 | HttpServer, |
| 11 | 12 | } from "@effect/platform"; |
| 12 | 13 | import { Effect, Layer, Scope } from "effect"; |
| @@ -76,6 +77,7 @@ const SharedServicesMemoized = Effect.runSync( · 63 unmodified lines | ||
| 76 | 77 | const publicApiHandler = HttpApiBuilder.toWebHandler( |
| 77 | 78 | PublicCloudApiLive.pipe( |
| 78 | 79 | Layer.provideMerge(SharedServicesMemoized), |
| 80 | + Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })), | |
| 79 | 81 | ), |
| 80 | 82 | { middleware: HttpMiddleware.logger }, |
| 81 | 83 | ); |
| @@ -184,6 +186,7 @@ const createProtectedHandler = ( · 102 unmodified lines | ||
| 184 | 186 | Layer.provideMerge(ProtectedCloudApiLive), |
| 185 | 187 | Layer.provideMerge(requestServices), |
| 186 | 188 | Layer.provideMerge(SharedServicesMemoized), |
| 189 | + Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })), | |
| 187 | 190 | ), |
| 188 | 191 | { middleware: HttpMiddleware.logger }, |
| 189 | 192 | ); |
| @@ -17,6 +17,9 @@ export const Route = createRootRoute({ · 16 unmodified lines | ||
| 17 | 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 | 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 | 24 | component: RootComponent, |
| 22 | 25 | shellComponent: RootDocument, |
| @@ -2,6 +2,7 @@ import { · 1 unmodified line | ||
| 2 | 2 | HttpApiBuilder, |
| 3 | 3 | HttpApiSwagger, |
| 4 | 4 | HttpMiddleware, |
| 5 | + HttpRouter, | |
| 5 | 6 | HttpServer, |
| 6 | 7 | } from "@effect/platform"; |
| 7 | 8 | import { Context, Effect, Layer, ManagedRuntime } from "effect"; |
| @@ -76,6 +77,7 @@ export const createServerHandlers = async (): Promise<ServerHandlers> => { · 68 unmodified lines | ||
| 76 | 77 | Layer.provideMerge(Layer.succeed(ExecutorService, executor)), |
| 77 | 78 | Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)), |
| 78 | 79 | Layer.provideMerge(HttpServer.layerContext), |
| 80 | + Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })), | |
| 79 | 81 | ), |
| 80 | 82 | { middleware: HttpMiddleware.logger }, |
| 81 | 83 | ); |
| @@ -33,6 +33,8 @@ const ToolSchemaResponse = Schema.Struct({ · 32 unmodified lines | ||
| 33 | 33 | typeScriptDefinitions: Schema.optional( |
| 34 | 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 | 9 | InvokeOptions, |
| 10 | 10 | RuntimeToolHandler, |
| 11 | 11 | } from "../tools"; |
| 12 | −import { normalizeRefs } from "../schema-refs"; | |
| 12 | +import { normalizeRefs, reattachDefs } from "../schema-refs"; | |
| 13 | 13 | import { buildToolTypeScriptPreview } from "../schema-types"; |
| 14 | 14 | |
| 15 | 15 | export const makeInMemoryToolRegistry = () => { |
| @@ -68,11 +68,19 @@ export const makeInMemoryToolRegistry = () => { · 52 unmodified lines | ||
| 68 | 68 | inputSchema: t.inputSchema, |
| 69 | 69 | outputSchema: t.outputSchema, |
| 70 | 70 | defs, |
| 71 | + options: { | |
| 72 | + maxLength: Infinity, | |
| 73 | + maxProperties: Infinity, | |
| 74 | + maxCompositeMembers: Infinity, | |
| 75 | + maxRefDepth: 20, | |
| 76 | + }, | |
| 71 | 77 | }); |
| 72 | 78 | |
| 73 | 79 | return { |
| 74 | 80 | id: t.id, |
| 75 | 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 | 36 | typeScriptDefinitions: Schema.optional( |
| 37 | 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 | 43 | export class ToolInvocationResult extends Schema.Class<ToolInvocationResult>( |
| @@ -12,7 +12,7 @@ import type { · 11 unmodified lines | ||
| 12 | 12 | InvokeOptions, |
| 13 | 13 | RuntimeToolHandler, |
| 14 | 14 | } from "@executor/sdk"; |
| 15 | −import { buildToolTypeScriptPreview } from "@executor/sdk"; | |
| 15 | +import { buildToolTypeScriptPreview, reattachDefs } from "@executor/sdk"; | |
| 16 | 16 | |
| 17 | 17 | // --------------------------------------------------------------------------- |
| 18 | 18 | // Serialization — leverage ToolRegistration Schema.Class directly |
| @@ -101,11 +101,19 @@ export const makeKvToolRegistry = ( · 82 unmodified lines | ||
| 101 | 101 | inputSchema: t.inputSchema, |
| 102 | 102 | outputSchema: t.outputSchema, |
| 103 | 103 | defs, |
| 104 | + options: { | |
| 105 | + maxLength: Infinity, | |
| 106 | + maxProperties: Infinity, | |
| 107 | + maxCompositeMembers: Infinity, | |
| 108 | + maxRefDepth: 20, | |
| 109 | + }, | |
| 104 | 110 | }); |
| 105 | 111 | |
| 106 | 112 | return { |
| 107 | 113 | id: t.id, |
| 108 | 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 | 2 | import { useAtomValue, Result } from "@effect-atom/atom-react"; |
| 3 | 3 | import { toolSchemaAtom } from "../api/atoms"; |
| 4 | 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 | 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 | 11 | // Copy button |
| @@ -27,48 +27,32 @@ function CopyButton(props: { text: string; label?: string }) { · 15 unmodified lines | ||
| 27 | 27 | title={props.label ?? "Copy"} |
| 28 | 28 | > |
| 29 | 29 | {copied ? ( |
| 30 | − <svg viewBox="0 0 16 16" className="size-3.5"> | |
| 31 | − <path d="M3 8l3 3 7-7" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /> | |
| 32 | − </svg> | |
| 30 | + <Check className="size-3.5 shrink-0" /> | |
| 33 | 31 | ) : ( |
| 34 | − <svg viewBox="0 0 16 16" className="size-3.5"> | |
| 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> | |
| 32 | + <Copy className="size-3.5 shrink-0" /> | |
| 38 | 33 | )} |
| 39 | 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 = ( | |
| 48 | − toolName: string, | |
| 49 | − inputTypeScript?: string, | |
| 50 | − outputTypeScript?: string, | |
| 51 | −): string => { | |
| 52 | − const inputType = inputTypeScript ?? "void"; | |
| 53 | − const outputType = outputTypeScript ?? "void"; | |
| 54 | − | |
| 55 | − const leaf = toolName.split(".").pop() ?? toolName; | |
| 56 | − return `declare function ${leaf}(input: ${inputType}): ${outputType}`; | |
| 42 | +const friendlyName = (name: string): string => { | |
| 43 | + const leaf = name.split(".").pop() ?? name; | |
| 44 | + return leaf | |
| 45 | + .replace(/([a-z])([A-Z])/g, "$1 $2") | |
| 46 | + .replace(/[_.-]/g, " ") | |
| 47 | + .replace(/\b\w/g, (c) => c.toUpperCase()); | |
| 57 | 48 | }; |
| 58 | 49 | |
| 59 | −const buildPrimaryContract = (input: { | |
| 60 | − toolName: string; | |
| 61 | − inputTypeScript?: string; | |
| 62 | − outputTypeScript?: string; | |
| 63 | −}): string => { | |
| 64 | − const sections = [ | |
| 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 | −}; | |
| 50 | +const breadcrumbParts = (name: string): string[] => | |
| 51 | + name.split(".").map((p) => | |
| 52 | + p.replace(/([a-z])([A-Z])/g, "$1 $2") | |
| 53 | + .replace(/[_-]/g, " ") | |
| 54 | + .replace(/\b\w/g, (c) => c.toUpperCase()), | |
| 55 | + ); | |
| 72 | 56 | |
| 73 | 57 | // --------------------------------------------------------------------------- |
| 74 | 58 | // ToolDetail |
| @@ -83,162 +67,166 @@ export function ToolDetail(props: { · 8 unmodified lines | ||
| 83 | 67 | const toolContract = useAtomValue( |
| 84 | 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 | 73 | if (!Result.isSuccess(toolContract)) return null; |
| 89 | 74 | const v = toolContract.value; |
| 90 | 75 | const definitions = Object.entries(v.typeScriptDefinitions ?? {}).map(([name, body]) => ({ |
| 91 | 76 | name, |
| 92 | − code: `type ${name} = ${body}`, | |
| 77 | + code: body, | |
| 93 | 78 | })); |
| 94 | 79 | |
| 95 | 80 | return { |
| 96 | − inputTypeScript: v.inputTypeScript, | |
| 97 | − outputTypeScript: v.outputTypeScript, | |
| 98 | − contract: buildPrimaryContract({ | |
| 99 | − toolName: props.toolName, | |
| 100 | − inputTypeScript: v.inputTypeScript, | |
| 101 | − outputTypeScript: v.outputTypeScript, | |
| 102 | − }), | |
| 81 | + inputSchema: v.inputSchema, | |
| 82 | + outputSchema: v.outputSchema, | |
| 83 | + inputTypeScript: v.inputTypeScript | |
| 84 | + ? `type Input = ${v.inputTypeScript}` | |
| 85 | + : null, | |
| 86 | + outputTypeScript: v.outputTypeScript | |
| 87 | + ? `type Output = ${v.outputTypeScript}` | |
| 88 | + : null, | |
| 103 | 89 | definitions, |
| 104 | 90 | }; |
| 105 | 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 | 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%)]"> | |
| 111 | − <div className="shrink-0 border-b border-border/70 bg-background/90 backdrop-blur-xl"> | |
| 112 | − <div className="px-5 py-4"> | |
| 113 | − <div className="flex items-start gap-4"> | |
| 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)]"> | |
| 115 | − <div className="absolute inset-0 rounded-2xl bg-[linear-gradient(135deg,rgba(255,255,255,0.14),transparent_55%)]" /> | |
| 116 | − <svg viewBox="0 0 16 16" className="relative size-4"> | |
| 117 | − <path | |
| 118 | − d="M4 2h8l1 3H3l1-3zM3 6h10v8H3V6z" | |
| 119 | − fill="none" | |
| 120 | − stroke="currentColor" | |
| 121 | − strokeWidth="1.2" | |
| 122 | − /> | |
| 123 | − </svg> | |
| 97 | + <div className="flex h-full flex-col overflow-hidden"> | |
| 98 | + {/* Header + tabs */} | |
| 99 | + <div className="shrink-0 border-b border-border/40"> | |
| 100 | + <div className="px-5 pt-4 pb-0"> | |
| 101 | + {crumbs.length > 1 && ( | |
| 102 | + <div className="flex items-center gap-1 text-sm text-muted-foreground/40"> | |
| 103 | + {crumbs.slice(0, -1).map((part, i) => ( | |
| 104 | + <span key={i} className="flex items-center gap-1"> | |
| 105 | + {i > 0 && <ChevronRight className="size-3 shrink-0" />} | |
| 106 | + <span>{part}</span> | |
| 107 | + </span> | |
| 108 | + ))} | |
| 124 | 109 | </div> |
| 125 | − <div className="min-w-0 flex-1"> | |
| 126 | − <div className="flex flex-wrap 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"> | |
| 128 | − {namespace} | |
| 129 | − </Badge> | |
| 130 | − <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.18em] text-muted-foreground/80"> | |
| 131 | − {types?.definitions.length ?? 0} shared type{(types?.definitions.length ?? 0) === 1 ? "" : "s"} | |
| 132 | − </Badge> | |
| 133 | − </div> | |
| 134 | − <div className="mt-2 flex items-start gap-2"> | |
| 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 | − )} | |
| 110 | + )} | |
| 111 | + <div className="mt-1 flex items-center gap-2"> | |
| 112 | + <h3 className="text-base font-semibold text-foreground truncate"> | |
| 113 | + {displayName} | |
| 114 | + </h3> | |
| 115 | + <CopyButton text={props.toolId} label="Copy tool ID" /> | |
| 116 | + </div> | |
| 117 | + {props.toolDescription && ( | |
| 118 | + <div className="mt-1.5 max-w-lg text-sm text-muted-foreground/70 line-clamp-2"> | |
| 119 | + <Markdown>{props.toolDescription}</Markdown> | |
| 145 | 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 | 153 | </div> |
| 147 | 154 | </div> |
| 148 | 155 | </div> |
| 149 | 156 | |
| 157 | + {/* Content */} | |
| 150 | 158 | <div className="flex-1 overflow-y-auto"> |
| 151 | 159 | {Result.match(toolContract, { |
| 152 | 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 | 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: () => ( | |
| 159 | − <div className="space-y-4 px-5 py-4"> | |
| 160 | − <div className="rounded-2xl border border-border/70 bg-card/70 shadow-[0_12px_40px_rgba(0,0,0,0.04)] overflow-hidden"> | |
| 161 | − <div className="flex items-center justify-between border-b border-border/70 bg-muted/30 px-4 py-3"> | |
| 162 | − <div> | |
| 163 | − <p className="text-[11px] font-medium uppercase tracking-[0.22em] text-muted-foreground/75"> | |
| 164 | − Primary contract | |
| 165 | − </p> | |
| 166 | − <p className="mt-1 text-xs text-muted-foreground/80"> | |
| 167 | − One compact view for the call signature, input, and output. | |
| 168 | − </p> | |
| 166 | + onSuccess: () => | |
| 167 | + tab === "schema" ? ( | |
| 168 | + <div className="px-5 py-5 space-y-6"> | |
| 169 | + <section> | |
| 170 | + <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> | |
| 171 | + Parameters | |
| 172 | + </h4> | |
| 173 | + <div className="mt-2.5"> | |
| 174 | + {data?.inputSchema ? ( | |
| 175 | + <SchemaExplorer schema={data.inputSchema} /> | |
| 176 | + ) : ( | |
| 177 | + <p className="py-2 text-sm text-muted-foreground/40">None</p> | |
| 178 | + )} | |
| 169 | 179 | </div> |
| 170 | − <div className="hidden items-center gap-2 sm:flex"> | |
| 171 | − <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.16em]"> | |
| 172 | − {types?.inputTypeScript ? "input" : "no input"} | |
| 173 | − </Badge> | |
| 174 | − <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.16em]"> | |
| 175 | − {types?.outputTypeScript ? "output" : "no output"} | |
| 176 | − </Badge> | |
| 180 | + </section> | |
| 181 | + | |
| 182 | + <section> | |
| 183 | + <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> | |
| 184 | + Response | |
| 185 | + </h4> | |
| 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 | 192 | </div> |
| 178 | − </div> | |
| 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> | |
| 193 | + </section> | |
| 188 | 194 | </div> |
| 189 | − | |
| 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)]"> | |
| 191 | − <div className="flex items-center justify-between gap-3"> | |
| 192 | − <div> | |
| 193 | − <p className="text-[11px] font-medium uppercase tracking-[0.22em] text-muted-foreground/75"> | |
| 194 | − Shared definitions | |
| 195 | − </p> | |
| 196 | − <p className="mt-1 text-xs text-muted-foreground/80"> | |
| 197 | − Referenced types stay tucked away until you need them. | |
| 198 | − </p> | |
| 195 | + ) : ( | |
| 196 | + <div className="px-5 py-5 space-y-6"> | |
| 197 | + <section> | |
| 198 | + <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> | |
| 199 | + Input | |
| 200 | + </h4> | |
| 201 | + <div className="mt-2.5"> | |
| 202 | + {data?.inputTypeScript ? ( | |
| 203 | + <ExpandableCodeBlock | |
| 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 | 210 | </div> |
| 200 | − <Badge variant="outline" className="rounded-full px-2.5 py-1 text-[10px] uppercase tracking-[0.16em]"> | |
| 201 | − {types?.definitions.length ?? 0} | |
| 202 | − </Badge> | |
| 203 | − </div> | |
| 204 | − | |
| 205 | − {types && types.definitions.length > 0 ? ( | |
| 206 | − <Accordion type="multiple" className="mt-3 divide-y divide-border/60"> | |
| 207 | − {types.definitions.map((definition) => ( | |
| 208 | − <AccordionItem key={definition.name} value={definition.name} className="border-none"> | |
| 209 | − <AccordionTrigger className="py-3 hover:no-underline"> | |
| 210 | − <div className="flex min-w-0 items-center gap-3"> | |
| 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"> | |
| 212 | − TS | |
| 213 | − </div> | |
| 214 | − <div className="min-w-0"> | |
| 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. | |
| 211 | + </section> | |
| 212 | + | |
| 213 | + <section> | |
| 214 | + <h4 className="text-[0.6875rem] font-medium uppercase tracking-widest text-muted-foreground/40"> | |
| 215 | + Output | |
| 216 | + </h4> | |
| 217 | + <div className="mt-2.5"> | |
| 218 | + {data?.outputTypeScript ? ( | |
| 219 | + <ExpandableCodeBlock | |
| 220 | + code={data.outputTypeScript} | |
| 221 | + definitions={data.definitions} | |
| 222 | + /> | |
| 223 | + ) : ( | |
| 224 | + <p className="py-2 text-sm text-muted-foreground/40">void</p> | |
| 225 | + )} | |
| 237 | 226 | </div> |
| 238 | − )} | |
| 227 | + </section> | |
| 239 | 228 | </div> |
| 240 | − </div> | |
| 241 | − ), | |
| 229 | + ), | |
| 242 | 230 | })} |
| 243 | 231 | </div> |
| 244 | 232 | </div> |
| @@ -257,12 +245,8 @@ export function ToolDetailEmpty(props: { hasTools: boolean }) { · 12 unmodified lines | ||
| 257 | 245 | {props.hasTools ? "Select a tool" : "No tools available"} |
| 258 | 246 | </p> |
| 259 | 247 | {props.hasTools && ( |
| 260 | − <p className="mt-1 text-xs text-muted-foreground"> | |
| 261 | − Choose from the list or press{" "} | |
| 262 | − <kbd className="rounded border border-border bg-muted px-1 py-px text-[10px]"> | |
| 263 | − / | |
| 264 | − </kbd>{" "} | |
| 265 | − to search. | |
| 248 | + <p className="mt-1.5 text-sm text-muted-foreground/50"> | |
| 249 | + Choose from the list to see what it does. | |
| 266 | 250 | </p> |
| 267 | 251 | )} |
| 268 | 252 | </div> |