Showing the changes to apps (8 of 14 files). Show all
Unified · Split
| @@ -11,15 +11,18 @@ · 10 unmodified lines | |||
| 11 | "dependencies": { | 11 | "dependencies": { |
| 12 | "@executor-v3/react": "workspace:*", | 12 | "@executor-v3/react": "workspace:*", |
| 13 | "@executor-v3/server": "workspace:*", | 13 | "@executor-v3/server": "workspace:*", |
| 14 | "@streamdown/code": "^1.0.3", | ||
| 14 | "@tanstack/react-router": "^1.132.7", | 15 | "@tanstack/react-router": "^1.132.7", |
| 15 | "class-variance-authority": "^0.7.1", | 16 | "class-variance-authority": "^0.7.1", |
| 16 | "clsx": "^2.1.1", | 17 | "clsx": "^2.1.1", |
| 17 | "effect": "catalog:", | 18 | "effect": "catalog:", |
| 19 | "prettier": "^3.8.1", | ||
| 18 | "react": "^19.2.0", | 20 | "react": "^19.2.0", |
| 19 | "react-dom": "^19.2.0", | 21 | "react-dom": "^19.2.0", |
| 20 | "react-grab": "^0.1.26", | 22 | "react-grab": "^0.1.26", |
| 21 | "scheduler": "^0.27.0", | 23 | "scheduler": "^0.27.0", |
| 22 | "shiki": "^4.0.1", | 24 | "shiki": "^4.0.1", |
| 25 | "streamdown": "^2.3.0", | ||
| 23 | "tailwind-merge": "^3.5.0" | 26 | "tailwind-merge": "^3.5.0" |
| 24 | }, | 27 | }, |
| 25 | "devDependencies": { | 28 | "devDependencies": { |
| @@ -1,15 +1,7 @@ | |||
| 1 | import { useMemo } from "react"; | ||
| 2 | import { cn } from "../lib/utils"; | 1 | import { cn } from "../lib/utils"; |
| 2 | import { useFormattedCode } from "../lib/format"; | ||
| 3 | import { CodeBlock } from "./code-block"; | 3 | import { CodeBlock } from "./code-block"; |
| 4 | 4 | ||
| 5 | function prettyJson(raw: string): string { | ||
| 6 | try { | ||
| 7 | return JSON.stringify(JSON.parse(raw), null, 2); | ||
| 8 | } catch { | ||
| 9 | return raw; | ||
| 10 | } | ||
| 11 | } | ||
| 12 | |||
| 13 | export function DocumentPanel(props: { | 5 | export function DocumentPanel(props: { |
| 14 | title: string; | 6 | title: string; |
| 15 | body: string | null; | 7 | body: string | null; |
| @@ -17,10 +9,7 @@ export function DocumentPanel(props: { · 1 unmodified line | |||
| 17 | empty: string; | 9 | empty: string; |
| 18 | compact?: boolean; | 10 | compact?: boolean; |
| 19 | }) { | 11 | }) { |
| 20 | const formatted = useMemo( | 12 | const formatted = useFormattedCode(props.body, props.lang); |
| 21 | () => (props.body ? prettyJson(props.body) : null), | ||
| 22 | [props.body], | ||
| 23 | ); | ||
| 24 | 13 | ||
| 25 | return ( | 14 | return ( |
| 26 | <section | 15 | <section |
| @@ -107,3 +107,13 @@ export function IconSpinner({ className, ...props }: IconProps) { · 106 unmodified lines | |||
| 107 | </svg> | 107 | </svg> |
| 108 | ); | 108 | ); |
| 109 | } | 109 | } |
| 110 | |||
| 111 | export function IconInfo({ className, ...props }: IconProps) { | ||
| 112 | return ( | ||
| 113 | <svg viewBox="0 0 16 16" fill="none" className={cn("size-4", className)} {...props}> | ||
| 114 | <circle cx="8" cy="8" r="5.75" stroke="currentColor" strokeWidth="1.2" /> | ||
| 115 | <path d="M8 7v3.25" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" /> | ||
| 116 | <circle cx="8" cy="4.75" r="0.8" fill="currentColor" /> | ||
| 117 | </svg> | ||
| 118 | ); | ||
| 119 | } | ||
| @@ -0,0 +1,53 @@ | |||
| 1 | import { Streamdown } from "streamdown"; | ||
| 2 | import { createCodePlugin } from "@streamdown/code"; | ||
| 3 | |||
| 4 | const codePlugin = createCodePlugin({ | ||
| 5 | themes: ["vitesse-dark", "vitesse-dark"], | ||
| 6 | }); | ||
| 7 | |||
| 8 | const PROSE_CLASSES = [ | ||
| 9 | "text-[13px] leading-relaxed text-muted-foreground", | ||
| 10 | // paragraphs | ||
| 11 | "[&_p]:mb-[0.4em] [&_p:last-child]:mb-0", | ||
| 12 | // bold | ||
| 13 | "[&_strong]:text-foreground [&_strong]:font-semibold", | ||
| 14 | "[&_b]:text-foreground [&_b]:font-semibold", | ||
| 15 | // inline code | ||
| 16 | "[&_code]:font-mono [&_code]:text-xs [&_code]:bg-muted [&_code]:border [&_code]:border-border", | ||
| 17 | "[&_code]:rounded-sm [&_code]:px-1.5 [&_code]:py-px [&_code]:text-primary", | ||
| 18 | // pre blocks | ||
| 19 | "[&_pre]:bg-muted [&_pre]:border [&_pre]:border-border [&_pre]:rounded-md", | ||
| 20 | "[&_pre]:px-3 [&_pre]:py-2 [&_pre]:overflow-x-auto [&_pre]:my-2 [&_pre]:text-xs [&_pre]:leading-relaxed", | ||
| 21 | "[&_pre_code]:bg-transparent [&_pre_code]:border-0 [&_pre_code]:p-0 [&_pre_code]:text-inherit", | ||
| 22 | // links | ||
| 23 | "[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2", | ||
| 24 | "[&_a]:decoration-primary/30 hover:[&_a]:decoration-primary/80", | ||
| 25 | // lists | ||
| 26 | "[&_ul]:pl-5 [&_ul]:my-1.5 [&_ol]:pl-5 [&_ol]:my-1.5", | ||
| 27 | "[&_li]:mb-0.5 [&_li_::marker]:text-muted-foreground", | ||
| 28 | // tables | ||
| 29 | "[&_table]:w-full [&_table]:border-collapse [&_table]:text-xs [&_table]:my-2", | ||
| 30 | "[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:bg-muted [&_th]:font-semibold [&_th]:text-foreground", | ||
| 31 | "[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1 [&_td]:text-left [&_td]:bg-background", | ||
| 32 | // headings | ||
| 33 | "[&_h1]:font-semibold [&_h1]:text-foreground [&_h1]:mt-2 [&_h1]:mb-1 [&_h1]:text-[15px]", | ||
| 34 | "[&_h2]:font-semibold [&_h2]:text-foreground [&_h2]:mt-2 [&_h2]:mb-1 [&_h2]:text-sm", | ||
| 35 | "[&_h3]:font-semibold [&_h3]:text-foreground [&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:text-[13px]", | ||
| 36 | "[&_h4]:font-semibold [&_h4]:text-foreground [&_h4]:mt-2 [&_h4]:mb-1 [&_h4]:text-[13px]", | ||
| 37 | // blockquote | ||
| 38 | "[&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:my-1.5 [&_blockquote]:text-muted-foreground", | ||
| 39 | // hr | ||
| 40 | "[&_hr]:border-0 [&_hr]:border-t [&_hr]:border-border [&_hr]:my-2", | ||
| 41 | // images | ||
| 42 | "[&_img]:max-w-full [&_img]:rounded", | ||
| 43 | ].join(" "); | ||
| 44 | |||
| 45 | export function Markdown(props: { children: string; className?: string }) { | ||
| 46 | return ( | ||
| 47 | <div className={props.className ? `${PROSE_CLASSES} ${props.className}` : PROSE_CLASSES}> | ||
| 48 | <Streamdown plugins={{ code: codePlugin }} controls={{ code: true }} linkSafety={{ enabled: false }}> | ||
| 49 | {props.children} | ||
| 50 | </Streamdown> | ||
| 51 | </div> | ||
| 52 | ); | ||
| 53 | } | ||
| @@ -1,31 +1,9 @@ | |||
| 1 | import { useState, useMemo } from "react"; | ||
| 2 | import { Link, Outlet, useMatchRoute } from "@tanstack/react-router"; | 1 | import { Link, Outlet, useMatchRoute } from "@tanstack/react-router"; |
| 3 | import { useSources, type Source } from "@executor-v3/react"; | 2 | import { useSources, type Source } from "@executor-v3/react"; |
| 4 | import { cn } from "../lib/utils"; | 3 | import { cn } from "../lib/utils"; |
| 5 | import { IconSources, IconChevron } from "./icons"; | 4 | import { IconSources } from "./icons"; |
| 6 | import { LoadableBlock } from "./loadable"; | 5 | import { LoadableBlock } from "./loadable"; |
| 7 | 6 | ||
| 8 | // ── Kind labels + ordering ─────────────────────────────────────────────── | ||
| 9 | |||
| 10 | const kindMeta: Record<string, { label: string; order: number }> = { | ||
| 11 | mcp: { label: "MCP", order: 0 }, | ||
| 12 | openapi: { label: "OpenAPI", order: 1 }, | ||
| 13 | graphql: { label: "GraphQL", order: 2 }, | ||
| 14 | internal: { label: "Internal", order: 3 }, | ||
| 15 | }; | ||
| 16 | |||
| 17 | function groupByKind(sources: ReadonlyArray<Source>) { | ||
| 18 | const groups = new Map<string, Source[]>(); | ||
| 19 | for (const s of sources) { | ||
| 20 | const list = groups.get(s.kind) ?? []; | ||
| 21 | list.push(s); | ||
| 22 | groups.set(s.kind, list); | ||
| 23 | } | ||
| 24 | return [...groups.entries()].sort( | ||
| 25 | (a, b) => (kindMeta[a[0]]?.order ?? 99) - (kindMeta[b[0]]?.order ?? 99), | ||
| 26 | ); | ||
| 27 | } | ||
| 28 | |||
| 29 | // ── Status dot color ───────────────────────────────────────────────────── | 7 | // ── Status dot color ───────────────────────────────────────────────────── |
| 30 | 8 | ||
| 31 | const statusColor: Record<string, string> = { | 9 | const statusColor: Record<string, string> = { |
| @@ -63,7 +41,7 @@ export function AppShell() { · 31 unmodified lines | |||
| 63 | <nav className="flex flex-1 flex-col p-2 overflow-y-auto"> | 41 | <nav className="flex flex-1 flex-col p-2 overflow-y-auto"> |
| 64 | <NavItem to="/" label="Dashboard" active={!!isHome} /> | 42 | <NavItem to="/" label="Dashboard" active={!!isHome} /> |
| 65 | 43 | ||
| 66 | {/* Sources tree */} | 44 | {/* Sources */} |
| 67 | <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50"> | 45 | <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50"> |
| 68 | Sources | 46 | Sources |
| 69 | </div> | 47 | </div> |
| @@ -74,7 +52,11 @@ export function AppShell() { · 4 unmodified lines | |||
| 74 | No sources yet | 52 | No sources yet |
| 75 | </div> | 53 | </div> |
| 76 | ) : ( | 54 | ) : ( |
| 77 | <SourceTree sources={items} matchRoute={matchRoute} /> | 55 | <div className="flex flex-col gap-px"> |
| 56 | {items.map((source) => ( | ||
| 57 | <SourceItem key={source.id} source={source} matchRoute={matchRoute} /> | ||
| 58 | ))} | ||
| 59 | </div> | ||
| 78 | ) | 60 | ) |
| 79 | } | 61 | } |
| 80 | </LoadableBlock> | 62 | </LoadableBlock> |
| @@ -96,87 +78,13 @@ export function AppShell() { · 15 unmodified lines | |||
| 96 | ); | 78 | ); |
| 97 | } | 79 | } |
| 98 | 80 | ||
| 99 | // ── SourceTree ─────────────────────────────────────────────────────────── | ||
| 100 | |||
| 101 | function SourceTree(props: { | ||
| 102 | sources: ReadonlyArray<Source>; | ||
| 103 | matchRoute: ReturnType<typeof useMatchRoute>; | ||
| 104 | }) { | ||
| 105 | const groups = useMemo(() => groupByKind(props.sources), [props.sources]); | ||
| 106 | |||
| 107 | // If there's only one kind, skip the group headers | ||
| 108 | if (groups.length === 1) { | ||
| 109 | return ( | ||
| 110 | <div className="flex flex-col gap-px"> | ||
| 111 | {groups[0]![1].map((source) => ( | ||
| 112 | <SourceItem key={source.id} source={source} matchRoute={props.matchRoute} depth={0} /> | ||
| 113 | ))} | ||
| 114 | </div> | ||
| 115 | ); | ||
| 116 | } | ||
| 117 | |||
| 118 | return ( | ||
| 119 | <div className="flex flex-col gap-0.5"> | ||
| 120 | {groups.map(([kind, sources]) => ( | ||
| 121 | <KindGroup key={kind} kind={kind} sources={sources} matchRoute={props.matchRoute} /> | ||
| 122 | ))} | ||
| 123 | </div> | ||
| 124 | ); | ||
| 125 | } | ||
| 126 | |||
| 127 | // ── KindGroup (collapsible) ────────────────────────────────────────────── | ||
| 128 | |||
| 129 | function KindGroup(props: { | ||
| 130 | kind: string; | ||
| 131 | sources: Source[]; | ||
| 132 | matchRoute: ReturnType<typeof useMatchRoute>; | ||
| 133 | }) { | ||
| 134 | // Auto-expand if any source in this group is active | ||
| 135 | const hasActive = props.sources.some((s) => | ||
| 136 | props.matchRoute({ to: "/sources/$sourceId", params: { sourceId: s.id }, fuzzy: true }), | ||
| 137 | ); | ||
| 138 | const [open, setOpen] = useState(true); | ||
| 139 | |||
| 140 | return ( | ||
| 141 | <div> | ||
| 142 | {/* Group header */} | ||
| 143 | <button | ||
| 144 | type="button" | ||
| 145 | onClick={() => setOpen((o) => !o)} | ||
| 146 | className="group flex w-full items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-muted-foreground" | ||
| 147 | > | ||
| 148 | <IconChevron | ||
| 149 | className={cn( | ||
| 150 | "size-2.5 shrink-0 text-muted-foreground/30 transition-transform duration-150", | ||
| 151 | open && "rotate-90", | ||
| 152 | )} | ||
| 153 | /> | ||
| 154 | <span className="uppercase tracking-wider">{kindMeta[props.kind]?.label ?? props.kind}</span> | ||
| 155 | <span className="ml-auto tabular-nums text-[10px] text-muted-foreground/30"> | ||
| 156 | {props.sources.length} | ||
| 157 | </span> | ||
| 158 | </button> | ||
| 159 | |||
| 160 | {/* Children */} | ||
| 161 | {open && ( | ||
| 162 | <div className="flex flex-col gap-px mt-px"> | ||
| 163 | {props.sources.map((source) => ( | ||
| 164 | <SourceItem key={source.id} source={source} matchRoute={props.matchRoute} depth={1} /> | ||
| 165 | ))} | ||
| 166 | </div> | ||
| 167 | )} | ||
| 168 | </div> | ||
| 169 | ); | ||
| 170 | } | ||
| 171 | |||
| 172 | // ── SourceItem ─────────────────────────────────────────────────────────── | 81 | // ── SourceItem ─────────────────────────────────────────────────────────── |
| 173 | 82 | ||
| 174 | function SourceItem(props: { | 83 | function SourceItem(props: { |
| 175 | source: Source; | 84 | source: Source; |
| 176 | matchRoute: ReturnType<typeof useMatchRoute>; | 85 | matchRoute: ReturnType<typeof useMatchRoute>; |
| 177 | depth: number; | ||
| 178 | }) { | 86 | }) { |
| 179 | const { source, matchRoute, depth } = props; | 87 | const { source, matchRoute } = props; |
| 180 | const active = matchRoute({ | 88 | const active = matchRoute({ |
| 181 | to: "/sources/$sourceId", | 89 | to: "/sources/$sourceId", |
| 182 | params: { sourceId: source.id }, | 90 | params: { sourceId: source.id }, |
| @@ -189,22 +97,14 @@ function SourceItem(props: { · 6 unmodified lines | |||
| 189 | params={{ sourceId: source.id }} | 97 | params={{ sourceId: source.id }} |
| 190 | search={{ tab: "model" }} | 98 | search={{ tab: "model" }} |
| 191 | className={cn( | 99 | className={cn( |
| 192 | "group flex items-center gap-2 rounded-md py-1.5 text-[12px] transition-colors", | 100 | "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors", |
| 193 | depth === 0 ? "px-2.5" : "pl-6 pr-2.5", | ||
| 194 | active | 101 | active |
| 195 | ? "bg-sidebar-active text-foreground font-medium" | 102 | ? "bg-sidebar-active text-foreground font-medium" |
| 196 | : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", | 103 | : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground", |
| 197 | )} | 104 | )} |
| 198 | > | 105 | > |
| 199 | {/* Tree connector line for nested items */} | ||
| 200 | {depth > 0 && ( | ||
| 201 | <span className="absolute left-[18px] h-full w-px bg-sidebar-border" aria-hidden /> | ||
| 202 | )} | ||
| 203 | |||
| 204 | <IconSources className="size-3 shrink-0 text-muted-foreground/40" /> | 106 | <IconSources className="size-3 shrink-0 text-muted-foreground/40" /> |
| 205 | <span className="flex-1 truncate">{source.name}</span> | 107 | <span className="flex-1 truncate">{source.name}</span> |
| 206 | |||
| 207 | {/* Status dot + tooltip */} | ||
| 208 | <span | 108 | <span |
| 209 | className={cn("size-1.5 shrink-0 rounded-full", statusColor[source.status] ?? "bg-muted-foreground/30")} | 109 | className={cn("size-1.5 shrink-0 rounded-full", statusColor[source.status] ?? "bg-muted-foreground/30")} |
| 210 | title={source.status} | 110 | title={source.status} |
| @@ -1,6 +1,8 @@ | |||
| 1 | @import "tailwindcss"; | 1 | @import "tailwindcss"; |
| 2 | @import "tw-animate-css"; | 2 | @import "tw-animate-css"; |
| 3 | 3 | ||
| 4 | @source "../../node_modules/streamdown/dist/*.js"; | ||
| 5 | |||
| 4 | @custom-variant dark (&:is(.dark *)); | 6 | @custom-variant dark (&:is(.dark *)); |
| 5 | 7 | ||
| 6 | @theme inline { | 8 | @theme inline { |
| @@ -105,3 +107,11 @@ · 98 unmodified lines | |||
| 105 | background: oklch(0.38 0.005 260); | 107 | background: oklch(0.38 0.005 260); |
| 106 | } | 108 | } |
| 107 | } | 109 | } |
| 110 | |||
| 111 | /* Streamdown code block overrides */ | ||
| 112 | [data-streamdown="code-block-body"] { | ||
| 113 | background-color: var(--muted) !important; | ||
| 114 | border: 1px solid var(--border); | ||
| 115 | border-radius: 0.375rem; | ||
| 116 | } | ||
| 117 | |||
| @@ -0,0 +1,133 @@ | |||
| 1 | import { useState, useEffect } from "react"; | ||
| 2 | |||
| 3 | // Cache formatted results so we don't re-format on every render | ||
| 4 | const formatCache = new Map<string, string>(); | ||
| 5 | |||
| 6 | function formatCacheKey(code: string, parser: string): string { | ||
| 7 | return `${parser}::${code.length}::${code}`; | ||
| 8 | } | ||
| 9 | |||
| 10 | /** | ||
| 11 | * Lazily load prettier + parsers and format code. | ||
| 12 | * Returns the original string if formatting fails. | ||
| 13 | */ | ||
| 14 | async function formatCode( | ||
| 15 | code: string, | ||
| 16 | parser: "json" | "typescript" | "babel", | ||
| 17 | ): Promise<string> { | ||
| 18 | const key = formatCacheKey(code, parser); | ||
| 19 | const cached = formatCache.get(key); | ||
| 20 | if (cached) return cached; | ||
| 21 | |||
| 22 | try { | ||
| 23 | const [prettier, parserPlugin] = await Promise.all([ | ||
| 24 | import("prettier/standalone"), | ||
| 25 | parser === "json" | ||
| 26 | ? import("prettier/plugins/babel") | ||
| 27 | : import("prettier/plugins/typescript"), | ||
| 28 | ]); | ||
| 29 | |||
| 30 | // both babel and typescript parsers need estree | ||
| 31 | const estree = await import("prettier/plugins/estree"); | ||
| 32 | // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
| 33 | const plugins: any[] = [parserPlugin, estree]; | ||
| 34 | |||
| 35 | let input = code; | ||
| 36 | let unwrap = false; | ||
| 37 | |||
| 38 | // Type signature strings (e.g. `{ body: { ... } }`) aren't valid TS | ||
| 39 | // on their own. Wrap as `type T = ...` so prettier can parse them. | ||
| 40 | if (parser === "typescript") { | ||
| 41 | input = `type __T = ${code}`; | ||
| 42 | unwrap = true; | ||
| 43 | } | ||
| 44 | |||
| 45 | const result = await prettier.format(input, { | ||
| 46 | parser: parser === "json" ? "json" : "typescript", | ||
| 47 | plugins, | ||
| 48 | printWidth: 60, | ||
| 49 | tabWidth: 2, | ||
| 50 | semi: true, | ||
| 51 | singleQuote: false, | ||
| 52 | trailingComma: "all", | ||
| 53 | }); | ||
| 54 | |||
| 55 | let trimmed = result.trimEnd(); | ||
| 56 | |||
| 57 | // Strip the `type __T = ` wrapper and trailing `;` | ||
| 58 | if (unwrap) { | ||
| 59 | trimmed = trimmed | ||
| 60 | .replace(/^type __T =\s*/, "") | ||
| 61 | .replace(/;$/, "") | ||
| 62 | .trimEnd(); | ||
| 63 | } | ||
| 64 | |||
| 65 | formatCache.set(key, trimmed); | ||
| 66 | return trimmed; | ||
| 67 | } catch { | ||
| 68 | // If formatting fails, return original | ||
| 69 | return code; | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | /** | ||
| 74 | * Detect the right parser for a code string. | ||
| 75 | */ | ||
| 76 | function detectParser( | ||
| 77 | code: string, | ||
| 78 | langHint?: string, | ||
| 79 | ): "json" | "typescript" | null { | ||
| 80 | if (langHint === "json" || langHint === "jsonc") return "json"; | ||
| 81 | if (langHint === "typescript" || langHint === "ts") return "typescript"; | ||
| 82 | |||
| 83 | const trimmed = code.trimStart(); | ||
| 84 | if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json"; | ||
| 85 | |||
| 86 | return null; | ||
| 87 | } | ||
| 88 | |||
| 89 | /** | ||
| 90 | * Hook: formats code with prettier. Returns the formatted string | ||
| 91 | * (or the original while loading / if formatting fails). | ||
| 92 | */ | ||
| 93 | export function useFormattedCode( | ||
| 94 | code: string | null, | ||
| 95 | langHint?: string, | ||
| 96 | ): string | null { | ||
| 97 | const [formatted, setFormatted] = useState<string | null>(code); | ||
| 98 | |||
| 99 | useEffect(() => { | ||
| 100 | if (!code) { | ||
| 101 | setFormatted(null); | ||
| 102 | return; | ||
| 103 | } | ||
| 104 | |||
| 105 | const parser = detectParser(code, langHint); | ||
| 106 | if (!parser) { | ||
| 107 | setFormatted(code); | ||
| 108 | return; | ||
| 109 | } | ||
| 110 | |||
| 111 | // Check cache synchronously first | ||
| 112 | const key = formatCacheKey(code, parser); | ||
| 113 | const cached = formatCache.get(key); | ||
| 114 | if (cached) { | ||
| 115 | setFormatted(cached); | ||
| 116 | return; | ||
| 117 | } | ||
| 118 | |||
| 119 | // Set original immediately, then replace with formatted | ||
| 120 | setFormatted(code); | ||
| 121 | |||
| 122 | let cancelled = false; | ||
| 123 | formatCode(code, parser).then((result) => { | ||
| 124 | if (!cancelled) setFormatted(result); | ||
| 125 | }); | ||
| 126 | |||
| 127 | return () => { | ||
| 128 | cancelled = true; | ||
| 129 | }; | ||
| 130 | }, [code, langHint]); | ||
| 131 | |||
| 132 | return formatted; | ||
| 133 | } | ||
| @@ -1,9 +1,10 @@ | |||
| 1 | import { useState, useEffect, useCallback, useRef } from "react"; | 1 | import { useState, useEffect, useCallback, useRef, useMemo } from "react"; |
| 2 | import { useNavigate } from "@tanstack/react-router"; | 2 | import { useNavigate } from "@tanstack/react-router"; |
| 3 | import { | 3 | import { |
| 4 | useSourceInspection, | 4 | useSourceInspection, |
| 5 | useSourceToolDetail, | 5 | useSourceToolDetail, |
| 6 | useSourceDiscovery, | 6 | useSourceDiscovery, |
| 7 | usePrefetchToolDetail, | ||
| 7 | type Loadable, | 8 | type Loadable, |
| 8 | type SourceInspection, | 9 | type SourceInspection, |
| 9 | type SourceInspectionToolDetail, | 10 | type SourceInspectionToolDetail, |
| @@ -18,11 +19,14 @@ import { · 8 unmodified lines | |||
| 18 | IconSearch, | 19 | IconSearch, |
| 19 | IconChevron, | 20 | IconChevron, |
| 20 | IconTool, | 21 | IconTool, |
| 22 | IconFolder, | ||
| 21 | IconCopy, | 23 | IconCopy, |
| 22 | IconCheck, | 24 | IconCheck, |
| 23 | IconClose, | 25 | IconClose, |
| 24 | IconEmpty, | 26 | IconEmpty, |
| 27 | IconInfo, | ||
| 25 | } from "../components/icons"; | 28 | } from "../components/icons"; |
| 29 | import { Markdown } from "../components/markdown"; | ||
| 26 | 30 | ||
| 27 | // --------------------------------------------------------------------------- | 31 | // --------------------------------------------------------------------------- |
| 28 | // Types | 32 | // Types |
| @@ -125,6 +129,7 @@ export function SourceDetailPage(props: { · 96 unmodified lines | |||
| 125 | onSelectTool={(toolPath) => | 129 | onSelectTool={(toolPath) => |
| 126 | void navigate({ search: (prev) => ({ ...prev, tool: toolPath, tab: "model" }) }) | 130 | void navigate({ search: (prev) => ({ ...prev, tool: toolPath, tab: "model" }) }) |
| 127 | } | 131 | } |
| 132 | sourceId={sourceId} | ||
| 128 | /> | 133 | /> |
| 129 | )} | 134 | )} |
| 130 | {search.tab === "discover" && ( | 135 | {search.tab === "discover" && ( |
| @@ -172,6 +177,7 @@ function ModelView(props: { · 41 unmodified lines | |||
| 172 | detail: Loadable<SourceInspectionToolDetail | null>; | 177 | detail: Loadable<SourceInspectionToolDetail | null>; |
| 173 | selectedToolPath: string | null; | 178 | selectedToolPath: string | null; |
| 174 | onSelectTool: (toolPath: string) => void; | 179 | onSelectTool: (toolPath: string) => void; |
| 180 | sourceId: string; | ||
| 175 | }) { | 181 | }) { |
| 176 | const [search, setSearch] = useState(""); | 182 | const [search, setSearch] = useState(""); |
| 177 | const searchRef = useRef<HTMLInputElement>(null); | 183 | const searchRef = useRef<HTMLInputElement>(null); |
| @@ -244,23 +250,22 @@ function ModelView(props: { · 66 unmodified lines | |||
| 244 | </span> | 250 | </span> |
| 245 | </div> | 251 | </div> |
| 246 | 252 | ||
| 247 | {/* Tool list */} | 253 | {/* Tool tree */} |
| 248 | <div className="flex-1 overflow-y-auto"> | 254 | <div className="flex-1 overflow-y-auto"> |
| 249 | {filteredTools.length === 0 ? ( | 255 | {filteredTools.length === 0 ? ( |
| 250 | <div className="p-4 text-center text-[13px] text-muted-foreground/50"> | 256 | <div className="p-4 text-center text-[13px] text-muted-foreground/50"> |
| 251 | {terms.length > 0 ? "No tools match your search" : "No tools available"} | 257 | {terms.length > 0 ? "No tools match your search" : "No tools available"} |
| 252 | </div> | 258 | </div> |
| 253 | ) : ( | 259 | ) : ( |
| 254 | <div className="p-1.5 space-y-px"> | 260 | <div className="p-1.5"> |
| 255 | {filteredTools.map((tool) => ( | 261 | <ToolTree |
| 256 | <ToolListItem | 262 | tools={filteredTools} |
| 257 | key={tool.path} | 263 | selectedToolPath={props.selectedToolPath} |
| 258 | tool={tool} | 264 | onSelectTool={props.onSelectTool} |
| 259 | active={tool.path === props.selectedToolPath} | 265 | search={search} |
| 260 | onSelect={() => props.onSelectTool(tool.path)} | 266 | isFiltered={terms.length > 0} |
| 261 | search={search} | 267 | sourceId={props.sourceId} |
| 262 | /> | 268 | /> |
| 263 | ))} | ||
| 264 | </div> | 269 | </div> |
| 265 | )} | 270 | )} |
| 266 | </div> | 271 | </div> |
| @@ -285,6 +290,215 @@ function ModelView(props: { · 18 unmodified lines | |||
| 285 | ); | 290 | ); |
| 286 | } | 291 | } |
| 287 | 292 | ||
| 293 | // --------------------------------------------------------------------------- | ||
| 294 | // Tool Tree (nested by . segments) | ||
| 295 | // --------------------------------------------------------------------------- | ||
| 296 | |||
| 297 | type ToolTreeNode = { | ||
| 298 | segment: string; | ||
| 299 | tool?: SourceInspection["tools"][number]; | ||
| 300 | children: Map<string, ToolTreeNode>; | ||
| 301 | }; | ||
| 302 | |||
| 303 | function buildToolTree(tools: SourceInspection["tools"]): ToolTreeNode { | ||
| 304 | const root: ToolTreeNode = { segment: "", children: new Map() }; | ||
| 305 | for (const tool of tools) { | ||
| 306 | const parts = tool.path.split("."); | ||
| 307 | let node = root; | ||
| 308 | for (const part of parts) { | ||
| 309 | if (!node.children.has(part)) { | ||
| 310 | node.children.set(part, { segment: part, children: new Map() }); | ||
| 311 | } | ||
| 312 | node = node.children.get(part)!; | ||
| 313 | } | ||
| 314 | node.tool = tool; | ||
| 315 | } | ||
| 316 | return root; | ||
| 317 | } | ||
| 318 | |||
| 319 | function ToolTree(props: { | ||
| 320 | tools: SourceInspection["tools"]; | ||
| 321 | selectedToolPath: string | null; | ||
| 322 | onSelectTool: (path: string) => void; | ||
| 323 | search: string; | ||
| 324 | isFiltered: boolean; | ||
| 325 | sourceId: string; | ||
| 326 | }) { | ||
| 327 | const tree = useMemo(() => buildToolTree(props.tools), [props.tools]); | ||
| 328 | const prefetch = usePrefetchToolDetail(); | ||
| 329 | const entries = [...tree.children.values()].sort((a, b) => | ||
| 330 | a.segment.localeCompare(b.segment), | ||
| 331 | ); | ||
| 332 | |||
| 333 | return ( | ||
| 334 | <div className="flex flex-col gap-px"> | ||
| 335 | {entries.map((node) => ( | ||
| 336 | <ToolTreeNodeView | ||
| 337 | key={node.segment} | ||
| 338 | node={node} | ||
| 339 | depth={0} | ||
| 340 | selectedToolPath={props.selectedToolPath} | ||
| 341 | onSelectTool={props.onSelectTool} | ||
| 342 | search={props.search} | ||
| 343 | defaultOpen={props.isFiltered} | ||
| 344 | sourceId={props.sourceId} | ||
| 345 | prefetch={prefetch} | ||
| 346 | /> | ||
| 347 | ))} | ||
| 348 | </div> | ||
| 349 | ); | ||
| 350 | } | ||
| 351 | |||
| 352 | function ToolTreeNodeView(props: { | ||
| 353 | node: ToolTreeNode; | ||
| 354 | depth: number; | ||
| 355 | selectedToolPath: string | null; | ||
| 356 | onSelectTool: (path: string) => void; | ||
| 357 | search: string; | ||
| 358 | defaultOpen: boolean; | ||
| 359 | sourceId: string; | ||
| 360 | prefetch: (sourceId: string, toolPath: string) => () => void; | ||
| 361 | }) { | ||
| 362 | const { node, depth, selectedToolPath, onSelectTool, search, defaultOpen, sourceId, prefetch } = props; | ||
| 363 | const hasChildren = node.children.size > 0; | ||
| 364 | const isLeaf = !!node.tool && !hasChildren; | ||
| 365 | |||
| 366 | // Check if any descendant is the selected tool | ||
| 367 | const hasSelectedDescendant = useMemo(() => { | ||
| 368 | if (!selectedToolPath) return false; | ||
| 369 | function check(n: ToolTreeNode): boolean { | ||
| 370 | if (n.tool?.path === selectedToolPath) return true; | ||
| 371 | for (const child of n.children.values()) { | ||
| 372 | if (check(child)) return true; | ||
| 373 | } | ||
| 374 | return false; | ||
| 375 | } | ||
| 376 | return check(node); | ||
| 377 | }, [node, selectedToolPath]); | ||
| 378 | |||
| 379 | const [open, setOpen] = useState(defaultOpen || hasSelectedDescendant); | ||
| 380 | |||
| 381 | // Auto-open when search filter is active or selection moves into this group | ||
| 382 | useEffect(() => { | ||
| 383 | if (defaultOpen || hasSelectedDescendant) setOpen(true); | ||
| 384 | }, [defaultOpen, hasSelectedDescendant]); | ||
| 385 | |||
| 386 | // Leaf: render as a tool item | ||
| 387 | if (isLeaf) { | ||
| 388 | return ( | ||
| 389 | <ToolListItem | ||
| 390 | tool={node.tool!} | ||
| 391 | active={node.tool!.path === selectedToolPath} | ||
| 392 | onSelect={() => onSelectTool(node.tool!.path)} | ||
| 393 | search={search} | ||
| 394 | depth={depth} | ||
| 395 | sourceId={sourceId} | ||
| 396 | prefetch={prefetch} | ||
| 397 | /> | ||
| 398 | ); | ||
| 399 | } | ||
| 400 | |||
| 401 | // Folder: collapsible group | ||
| 402 | const paddingLeft = 8 + depth * 16; | ||
| 403 | const sortedChildren = [...node.children.values()].sort((a, b) => | ||
| 404 | a.segment.localeCompare(b.segment), | ||
| 405 | ); | ||
| 406 | |||
| 407 | const leafCount = countToolLeaves(node); | ||
| 408 | |||
| 409 | return ( | ||
| 410 | <div> | ||
| 411 | {node.tool ? ( | ||
| 412 | // Node is both a folder AND a tool (rare) | ||
| 413 | <div className="flex items-center"> | ||
| 414 | <button | ||
| 415 | type="button" | ||
| 416 | onClick={() => setOpen((o) => !o)} | ||
| 417 | className="shrink-0 rounded p-0.5 text-muted-foreground/30 hover:text-muted-foreground" | ||
| 418 | style={{ marginLeft: paddingLeft }} | ||
| 419 | > | ||
| 420 | <IconChevron | ||
| 421 | className={cn( | ||
| 422 | "size-2.5 transition-transform duration-150", | ||
| 423 | open && "rotate-90", | ||
| 424 | )} | ||
| 425 | /> | ||
| 426 | </button> | ||
| 427 | <ToolListItem | ||
| 428 | tool={node.tool} | ||
| 429 | active={node.tool.path === selectedToolPath} | ||
| 430 | onSelect={() => onSelectTool(node.tool!.path)} | ||
| 431 | search={search} | ||
| 432 | depth={-1} | ||
| 433 | className="flex-1 pl-1" | ||
| 434 | sourceId={sourceId} | ||
| 435 | prefetch={prefetch} | ||
| 436 | /> | ||
| 437 | </div> | ||
| 438 | ) : ( | ||
| 439 | // Pure folder | ||
| 440 | <button | ||
| 441 | type="button" | ||
| 442 | onClick={() => setOpen((o) => !o)} | ||
| 443 | className={cn( | ||
| 444 | "group flex w-full items-center gap-1.5 rounded-md py-1 pr-2.5 text-[12px] transition-colors hover:bg-accent/40", | ||
| 445 | open ? "text-foreground/80" : "text-muted-foreground/60", | ||
| 446 | )} | ||
| 447 | style={{ paddingLeft }} | ||
| 448 | > | ||
| 449 | <IconChevron | ||
| 450 | className={cn( | ||
| 451 | "size-2.5 shrink-0 text-muted-foreground/30 transition-transform duration-150", | ||
| 452 | open && "rotate-90", | ||
| 453 | )} | ||
| 454 | /> | ||
| 455 | <IconFolder className={cn( | ||
| 456 | "size-3 shrink-0", | ||
| 457 | open ? "text-primary/60" : "text-muted-foreground/30", | ||
| 458 | )} /> | ||
| 459 | <span className="flex-1 truncate text-left font-mono"> | ||
| 460 | {highlightMatch(node.segment, search)} | ||
| 461 | </span> | ||
| 462 | <span className="shrink-0 tabular-nums text-[10px] text-muted-foreground/25"> | ||
| 463 | {leafCount} | ||
| 464 | </span> | ||
| 465 | </button> | ||
| 466 | )} | ||
| 467 | |||
| 468 | {open && hasChildren && ( | ||
| 469 | <div className="relative flex flex-col gap-px"> | ||
| 470 | <span | ||
| 471 | className="absolute top-0 bottom-1 w-px bg-border/40" | ||
| 472 | style={{ left: paddingLeft + 5 }} | ||
| 473 | aria-hidden | ||
| 474 | /> | ||
| 475 | {sortedChildren.map((child) => ( | ||
| 476 | <ToolTreeNodeView | ||
| 477 | key={child.segment} | ||
| 478 | node={child} | ||
| 479 | depth={depth + 1} | ||
| 480 | selectedToolPath={selectedToolPath} | ||
| 481 | onSelectTool={onSelectTool} | ||
| 482 | search={search} | ||
| 483 | defaultOpen={defaultOpen} | ||
| 484 | sourceId={sourceId} | ||
| 485 | prefetch={prefetch} | ||
| 486 | /> | ||
| 487 | ))} | ||
| 488 | </div> | ||
| 489 | )} | ||
| 490 | </div> | ||
| 491 | ); | ||
| 492 | } | ||
| 493 | |||
| 494 | function countToolLeaves(node: ToolTreeNode): number { | ||
| 495 | let count = node.tool ? 1 : 0; | ||
| 496 | for (const child of node.children.values()) { | ||
| 497 | count += countToolLeaves(child); | ||
| 498 | } | ||
| 499 | return count; | ||
| 500 | } | ||
| 501 | |||
| 288 | // --------------------------------------------------------------------------- | 502 | // --------------------------------------------------------------------------- |
| 289 | // ToolListItem | 503 | // ToolListItem |
| 290 | // --------------------------------------------------------------------------- | 504 | // --------------------------------------------------------------------------- |
| @@ -294,8 +508,14 @@ function ToolListItem(props: { · 3 unmodified lines | |||
| 294 | active: boolean; | 508 | active: boolean; |
| 295 | onSelect: () => void; | 509 | onSelect: () => void; |
| 296 | search: string; | 510 | search: string; |
| 511 | depth: number; | ||
| 512 | className?: string; | ||
| 513 | sourceId: string; | ||
| 514 | prefetch: (sourceId: string, toolPath: string) => () => void; | ||
| 297 | }) { | 515 | }) { |
| 298 | const ref = useRef<HTMLButtonElement>(null); | 516 | const ref = useRef<HTMLButtonElement>(null); |
| 517 | const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined; | ||
| 518 | const prefetchedRef = useRef(false); | ||
| 299 | 519 | ||
| 300 | useEffect(() => { | 520 | useEffect(() => { |
| 301 | if (props.active && ref.current) { | 521 | if (props.active && ref.current) { |
| @@ -303,21 +523,47 @@ function ToolListItem(props: { · 1 unmodified line | |||
| 303 | } | 523 | } |
| 304 | }, [props.active]); | 524 | }, [props.active]); |
| 305 | 525 | ||
| 526 | // Prefetch when visible in viewport | ||
| 527 | useEffect(() => { | ||
| 528 | const el = ref.current; | ||
| 529 | if (!el || prefetchedRef.current) return; | ||
| 530 | |||
| 531 | const observer = new IntersectionObserver( | ||
| 532 | (entries) => { | ||
| 533 | if (entries[0]?.isIntersecting && !prefetchedRef.current) { | ||
| 534 | prefetchedRef.current = true; | ||
| 535 | props.prefetch(props.sourceId, props.tool.path); | ||
| 536 | observer.disconnect(); | ||
| 537 | } | ||
| 538 | }, | ||
| 539 | { threshold: 0 }, | ||
| 540 | ); | ||
| 541 | observer.observe(el); | ||
| 542 | return () => observer.disconnect(); | ||
| 543 | }, [props.prefetch, props.sourceId, props.tool.path]); | ||
| 544 | |||
| 545 | // Show only the last segment (the actual method name) since parents show the namespace | ||
| 546 | const label = props.depth >= 0 | ||
| 547 | ? props.tool.path.split(".").pop() ?? props.tool.path | ||
| 548 | : props.tool.path; | ||
| 549 | |||
| 306 | return ( | 550 | return ( |
| 307 | <button | 551 | <button |
| 308 | ref={ref} | 552 | ref={ref} |
| 309 | type="button" | 553 | type="button" |
| 310 | onClick={props.onSelect} | 554 | onClick={props.onSelect} |
| 311 | className={cn( | 555 | className={cn( |
| 312 | "group flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left transition-colors", | 556 | "group flex w-full items-center gap-2 rounded-md py-1.5 pr-2.5 text-left transition-colors", |
| 313 | props.active | 557 | props.active |
| 314 | ? "bg-primary/10 text-foreground border-l-2 border-l-primary -ml-px" | 558 | ? "bg-primary/10 text-foreground border-l-2 border-l-primary" |
| 315 | : "hover:bg-accent/50 text-foreground/70 hover:text-foreground", | 559 | : "hover:bg-accent/50 text-foreground/70 hover:text-foreground", |
| 560 | props.className, | ||
| 316 | )} | 561 | )} |
| 562 | style={paddingLeft != null ? { paddingLeft } : undefined} | ||
| 317 | > | 563 | > |
| 318 | <IconTool className="size-3 shrink-0 text-muted-foreground/40" /> | 564 | <IconTool className="size-3 shrink-0 text-muted-foreground/40" /> |
| 319 | <span className="flex-1 truncate font-mono text-[12px]"> | 565 | <span className="flex-1 truncate font-mono text-[12px]"> |
| 320 | {highlightMatch(props.tool.path, props.search)} | 566 | {highlightMatch(label, props.search)} |
| 321 | </span> | 567 | </span> |
| 322 | {props.tool.method && <MethodBadge method={props.tool.method} />} | 568 | {props.tool.method && <MethodBadge method={props.tool.method} />} |
| 323 | </button> | 569 | </button> |
| @@ -328,9 +574,34 @@ function ToolListItem(props: { · 4 unmodified lines | |||
| 328 | // ToolDetailPanel | 574 | // ToolDetailPanel |
| 329 | // --------------------------------------------------------------------------- | 575 | // --------------------------------------------------------------------------- |
| 330 | 576 | ||
| 577 | type OutputTypeExplanation = { | ||
| 578 | title: string; | ||
| 579 | description: string; | ||
| 580 | }; | ||
| 581 | |||
| 582 | const getOutputTypeExplanation = (outputType: string | null | undefined): OutputTypeExplanation | null => { | ||
| 583 | if (outputType === "void") { | ||
| 584 | return { | ||
| 585 | title: "No response body", | ||
| 586 | description: "This operation succeeds without returning JSON. The HTTP response carries status only.", | ||
| 587 | }; | ||
| 588 | } | ||
| 589 | |||
| 590 | if (outputType === "{}") { | ||
| 591 | return { | ||
| 592 | title: "Empty JSON object", | ||
| 593 | description: "This operation returns a response body, but the schema has no fields.", | ||
| 594 | }; | ||
| 595 | } | ||
| 596 | |||
| 597 | return null; | ||
| 598 | }; | ||
| 599 | |||
| 600 | |||
| 331 | function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) { | 601 | function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) { |
| 332 | const { detail } = props; | 602 | const { detail } = props; |
| 333 | const [copiedField, setCopiedField] = useState<string | null>(null); | 603 | const [copiedField, setCopiedField] = useState<string | null>(null); |
| 604 | const outputTypeExplanation = getOutputTypeExplanation(detail.summary.outputType); | ||
| 334 | 605 | ||
| 335 | const copy = useCallback((text: string, field: string) => { | 606 | const copy = useCallback((text: string, field: string) => { |
| 336 | void navigator.clipboard.writeText(text).then(() => { | 607 | void navigator.clipboard.writeText(text).then(() => { |
| @@ -368,13 +639,36 @@ function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) { · 31 unmodified lines | |||
| 368 | 639 | ||
| 369 | {/* Scrollable body */} | 640 | {/* Scrollable body */} |
| 370 | <div className="flex-1 overflow-y-auto"> | 641 | <div className="flex-1 overflow-y-auto"> |
| 371 | <div className="px-5 py-4 space-y-5"> | 642 | <div className="px-5 py-4 space-y-4"> |
| 372 | {/* Summary cards */} | 643 | {/* Description */} |
| 373 | <div className="grid grid-cols-2 gap-2 lg:grid-cols-4"> | 644 | {detail.summary.description && ( |
| 374 | <MetricCard label="Input type" value={detail.summary.inputType ?? "unknown"} /> | 645 | <Markdown>{detail.summary.description}</Markdown> |
| 375 | <MetricCard label="Output type" value={detail.summary.outputType ?? "unknown"} /> | 646 | )} |
| 376 | <MetricCard label="Tool ID" value={detail.summary.toolId} mono /> | 647 | |
| 377 | <MetricCard label="Path template" value={detail.summary.pathTemplate ?? "n/a"} mono /> | 648 | {/* Type signatures */} |
| 649 | <div className="grid grid-cols-1 gap-3 xl:grid-cols-2"> | ||
| 650 | <DocumentPanel title="Input type" body={detail.summary.inputType ?? null} lang="typescript" empty="No input type." /> | ||
| 651 | <DocumentPanel title="Output type" body={detail.summary.outputType ?? null} lang="typescript" empty="No output type." /> | ||
| 652 | </div> | ||
| 653 | {outputTypeExplanation && ( | ||
| 654 | <div className="flex items-start gap-2 rounded-lg border border-border bg-card/40 px-3 py-2.5 text-[12px] text-muted-foreground"> | ||
| 655 | <IconInfo className="mt-0.5 size-3.5 shrink-0 text-primary/80" /> | ||
| 656 | <div> | ||
| 657 | <div className="font-medium text-foreground/90">{outputTypeExplanation.title}</div> | ||
| 658 | <p className="mt-0.5 leading-relaxed">{outputTypeExplanation.description}</p> | ||
| 659 | </div> | ||
| 660 | </div> | ||
| 661 | )} | ||
| 662 | |||
| 663 | {/* Metadata row */} | ||
| 664 | <div className="flex flex-wrap items-center gap-2 text-[11px]"> | ||
| 665 | <MetricPill label="Tool ID" value={detail.summary.toolId} /> | ||
| 666 | {detail.summary.pathTemplate && ( | ||
| 667 | <MetricPill label="Path" value={detail.summary.pathTemplate} /> | ||
| 668 | )} | ||
| 669 | {detail.summary.method && ( | ||
| 670 | <MethodBadge method={detail.summary.method} /> | ||
| 671 | )} | ||
| 378 | </div> | 672 | </div> |
| 379 | 673 | ||
| 380 | {/* Schema panels */} | 674 | {/* Schema panels */} |
| @@ -531,6 +825,20 @@ function MetricCard(props: { label: string; value: string; mono?: boolean }) { · 150 unmodified lines | |||
| 531 | ); | 825 | ); |
| 532 | } | 826 | } |
| 533 | 827 | ||
| 828 | function MetricPill(props: { label: string; value: string }) { | ||
| 829 | return ( | ||
| 830 | <span className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card/60 px-2 py-1"> | ||
| 831 | <span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/50"> | ||
| 832 | {props.label} | ||
| 833 | </span> | ||
| 834 | <span className="font-mono text-[11px] text-foreground/70"> | ||
| 835 | {props.value} | ||
| 836 | </span> | ||
| 837 | </span> | ||
| 838 | ); | ||
| 839 | } | ||
| 840 | |||
| 841 | |||
| 534 | function CopyButton(props: { | 842 | function CopyButton(props: { |
| 535 | text: string; | 843 | text: string; |
| 536 | field: string; | 844 | field: string; |