History

nicer ui

Rhys Sullivan committed 8 months ago

Commit ece99cf1 parent 8f20d5b14 files · +843 −160Browse files

Showing the changes to apps (8 of 14 files). Show all

Unified · Split

8 files
  1. apps/web/package.json +3 −0
  2. apps/web/src/components/document-panel.tsx +2 −13
  3. apps/web/src/components/icons.tsx +10 −0
  4. apps/web/src/components/markdown.tsx +53 −0
  5. apps/web/src/components/shell.tsx +9 −109
  6. apps/web/src/globals.css +10 −0
  7. apps/web/src/lib/format.ts +133 −0
  8. apps/web/src/views/source-detail.tsx +330 −22
apps/web/package.json+3−0
@@ -11,15 +11,18 @@ · 10 unmodified lines
1111 "dependencies": {
1212 "@executor-v3/react": "workspace:*",
1313 "@executor-v3/server": "workspace:*",
14+ "@streamdown/code": "^1.0.3",
1415 "@tanstack/react-router": "^1.132.7",
1516 "class-variance-authority": "^0.7.1",
1617 "clsx": "^2.1.1",
1718 "effect": "catalog:",
19+ "prettier": "^3.8.1",
1820 "react": "^19.2.0",
1921 "react-dom": "^19.2.0",
2022 "react-grab": "^0.1.26",
2123 "scheduler": "^0.27.0",
2224 "shiki": "^4.0.1",
25+ "streamdown": "^2.3.0",
2326 "tailwind-merge": "^3.5.0"
2427 },
2528 "devDependencies": {
apps/web/src/components/document-panel.tsx+2−13
@@ -1,15 +1,7 @@
1−import { useMemo } from "react";
21 import { cn } from "../lib/utils";
2+import { useFormattedCode } from "../lib/format";
33 import { CodeBlock } from "./code-block";
44
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−
135 export function DocumentPanel(props: {
146 title: string;
157 body: string | null;
@@ -17,10 +9,7 @@ export function DocumentPanel(props: { · 1 unmodified line
179 empty: string;
1810 compact?: boolean;
1911 }) {
20− const formatted = useMemo(
21− () => (props.body ? prettyJson(props.body) : null),
22− [props.body],
23− );
12+ const formatted = useFormattedCode(props.body, props.lang);
2413
2514 return (
2615 <section
apps/web/src/components/icons.tsx+10−0
@@ -107,3 +107,13 @@ export function IconSpinner({ className, ...props }: IconProps) { · 106 unmodified lines
107107 </svg>
108108 );
109109 }
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+}
apps/web/src/components/markdown.tsx+53−0
@@ -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+}
apps/web/src/components/shell.tsx+9−109
@@ -1,31 +1,9 @@
1−import { useState, useMemo } from "react";
21 import { Link, Outlet, useMatchRoute } from "@tanstack/react-router";
32 import { useSources, type Source } from "@executor-v3/react";
43 import { cn } from "../lib/utils";
5−import { IconSources, IconChevron } from "./icons";
4+import { IconSources } from "./icons";
65 import { LoadableBlock } from "./loadable";
76
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−
297 // ── Status dot color ─────────────────────────────────────────────────────
308
319 const statusColor: Record<string, string> = {
@@ -63,7 +41,7 @@ export function AppShell() { · 31 unmodified lines
6341 <nav className="flex flex-1 flex-col p-2 overflow-y-auto">
6442 <NavItem to="/" label="Dashboard" active={!!isHome} />
6543
66− {/* Sources tree */}
44+ {/* Sources */}
6745 <div className="mt-5 mb-1 px-2.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground/50">
6846 Sources
6947 </div>
@@ -74,7 +52,11 @@ export function AppShell() { · 4 unmodified lines
7452 No sources yet
7553 </div>
7654 ) : (
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>
7860 )
7961 }
8062 </LoadableBlock>
@@ -96,87 +78,13 @@ export function AppShell() { · 15 unmodified lines
9678 );
9779 }
9880
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−
17281 // ── SourceItem ───────────────────────────────────────────────────────────
17382
17483 function SourceItem(props: {
17584 source: Source;
17685 matchRoute: ReturnType<typeof useMatchRoute>;
177− depth: number;
17886 }) {
179− const { source, matchRoute, depth } = props;
87+ const { source, matchRoute } = props;
18088 const active = matchRoute({
18189 to: "/sources/$sourceId",
18290 params: { sourceId: source.id },
@@ -189,22 +97,14 @@ function SourceItem(props: { · 6 unmodified lines
18997 params={{ sourceId: source.id }}
19098 search={{ tab: "model" }}
19199 className={cn(
192− "group flex items-center gap-2 rounded-md py-1.5 text-[12px] transition-colors",
193− depth === 0 ? "px-2.5" : "pl-6 pr-2.5",
100+ "group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors",
194101 active
195102 ? "bg-sidebar-active text-foreground font-medium"
196103 : "text-sidebar-foreground hover:bg-sidebar-active/60 hover:text-foreground",
197104 )}
198105 >
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−
204106 <IconSources className="size-3 shrink-0 text-muted-foreground/40" />
205107 <span className="flex-1 truncate">{source.name}</span>
206−
207− {/* Status dot + tooltip */}
208108 <span
209109 className={cn("size-1.5 shrink-0 rounded-full", statusColor[source.status] ?? "bg-muted-foreground/30")}
210110 title={source.status}
apps/web/src/globals.css+10−0
@@ -1,6 +1,8 @@
11 @import "tailwindcss";
22 @import "tw-animate-css";
33
4+@source "../../node_modules/streamdown/dist/*.js";
5+
46 @custom-variant dark (&:is(.dark *));
57
68 @theme inline {
@@ -105,3 +107,11 @@ · 98 unmodified lines
105107 background: oklch(0.38 0.005 260);
106108 }
107109 }
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+
apps/web/src/lib/format.ts+133−0
@@ -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+}
apps/web/src/views/source-detail.tsx+330−22
@@ -1,9 +1,10 @@
1−import { useState, useEffect, useCallback, useRef } from "react";
1+import { useState, useEffect, useCallback, useRef, useMemo } from "react";
22 import { useNavigate } from "@tanstack/react-router";
33 import {
44 useSourceInspection,
55 useSourceToolDetail,
66 useSourceDiscovery,
7+ usePrefetchToolDetail,
78 type Loadable,
89 type SourceInspection,
910 type SourceInspectionToolDetail,
@@ -18,11 +19,14 @@ import { · 8 unmodified lines
1819 IconSearch,
1920 IconChevron,
2021 IconTool,
22+ IconFolder,
2123 IconCopy,
2224 IconCheck,
2325 IconClose,
2426 IconEmpty,
27+ IconInfo,
2528 } from "../components/icons";
29+import { Markdown } from "../components/markdown";
2630
2731 // ---------------------------------------------------------------------------
2832 // Types
@@ -125,6 +129,7 @@ export function SourceDetailPage(props: { · 96 unmodified lines
125129 onSelectTool={(toolPath) =>
126130 void navigate({ search: (prev) => ({ ...prev, tool: toolPath, tab: "model" }) })
127131 }
132+ sourceId={sourceId}
128133 />
129134 )}
130135 {search.tab === "discover" && (
@@ -172,6 +177,7 @@ function ModelView(props: { · 41 unmodified lines
172177 detail: Loadable<SourceInspectionToolDetail | null>;
173178 selectedToolPath: string | null;
174179 onSelectTool: (toolPath: string) => void;
180+ sourceId: string;
175181 }) {
176182 const [search, setSearch] = useState("");
177183 const searchRef = useRef<HTMLInputElement>(null);
@@ -244,23 +250,22 @@ function ModelView(props: { · 66 unmodified lines
244250 </span>
245251 </div>
246252
247− {/* Tool list */}
253+ {/* Tool tree */}
248254 <div className="flex-1 overflow-y-auto">
249255 {filteredTools.length === 0 ? (
250256 <div className="p-4 text-center text-[13px] text-muted-foreground/50">
251257 {terms.length > 0 ? "No tools match your search" : "No tools available"}
252258 </div>
253259 ) : (
254− <div className="p-1.5 space-y-px">
255− {filteredTools.map((tool) => (
256− <ToolListItem
257− key={tool.path}
258− tool={tool}
259− active={tool.path === props.selectedToolPath}
260− onSelect={() => props.onSelectTool(tool.path)}
261− search={search}
262− />
263− ))}
260+ <div className="p-1.5">
261+ <ToolTree
262+ tools={filteredTools}
263+ selectedToolPath={props.selectedToolPath}
264+ onSelectTool={props.onSelectTool}
265+ search={search}
266+ isFiltered={terms.length > 0}
267+ sourceId={props.sourceId}
268+ />
264269 </div>
265270 )}
266271 </div>
@@ -285,6 +290,215 @@ function ModelView(props: { · 18 unmodified lines
285290 );
286291 }
287292
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+
288502 // ---------------------------------------------------------------------------
289503 // ToolListItem
290504 // ---------------------------------------------------------------------------
@@ -294,8 +508,14 @@ function ToolListItem(props: { · 3 unmodified lines
294508 active: boolean;
295509 onSelect: () => void;
296510 search: string;
511+ depth: number;
512+ className?: string;
513+ sourceId: string;
514+ prefetch: (sourceId: string, toolPath: string) => () => void;
297515 }) {
298516 const ref = useRef<HTMLButtonElement>(null);
517+ const paddingLeft = props.depth >= 0 ? 8 + props.depth * 16 + 8 : undefined;
518+ const prefetchedRef = useRef(false);
299519
300520 useEffect(() => {
301521 if (props.active && ref.current) {
@@ -303,21 +523,47 @@ function ToolListItem(props: { · 1 unmodified line
303523 }
304524 }, [props.active]);
305525
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+
306550 return (
307551 <button
308552 ref={ref}
309553 type="button"
310554 onClick={props.onSelect}
311555 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",
313557 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"
315559 : "hover:bg-accent/50 text-foreground/70 hover:text-foreground",
560+ props.className,
316561 )}
562+ style={paddingLeft != null ? { paddingLeft } : undefined}
317563 >
318564 <IconTool className="size-3 shrink-0 text-muted-foreground/40" />
319565 <span className="flex-1 truncate font-mono text-[12px]">
320− {highlightMatch(props.tool.path, props.search)}
566+ {highlightMatch(label, props.search)}
321567 </span>
322568 {props.tool.method && <MethodBadge method={props.tool.method} />}
323569 </button>
@@ -328,9 +574,34 @@ function ToolListItem(props: { · 4 unmodified lines
328574 // ToolDetailPanel
329575 // ---------------------------------------------------------------------------
330576
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+
331601 function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) {
332602 const { detail } = props;
333603 const [copiedField, setCopiedField] = useState<string | null>(null);
604+ const outputTypeExplanation = getOutputTypeExplanation(detail.summary.outputType);
334605
335606 const copy = useCallback((text: string, field: string) => {
336607 void navigator.clipboard.writeText(text).then(() => {
@@ -368,13 +639,36 @@ function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) { · 31 unmodified lines
368639
369640 {/* Scrollable body */}
370641 <div className="flex-1 overflow-y-auto">
371− <div className="px-5 py-4 space-y-5">
372− {/* Summary cards */}
373− <div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
374− <MetricCard label="Input type" value={detail.summary.inputType ?? "unknown"} />
375− <MetricCard label="Output type" value={detail.summary.outputType ?? "unknown"} />
376− <MetricCard label="Tool ID" value={detail.summary.toolId} mono />
377− <MetricCard label="Path template" value={detail.summary.pathTemplate ?? "n/a"} mono />
642+ <div className="px-5 py-4 space-y-4">
643+ {/* Description */}
644+ {detail.summary.description && (
645+ <Markdown>{detail.summary.description}</Markdown>
646+ )}
647+
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+ )}
378672 </div>
379673
380674 {/* Schema panels */}
@@ -531,6 +825,20 @@ function MetricCard(props: { label: string; value: string; mono?: boolean }) { · 150 unmodified lines
531825 );
532826 }
533827
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+
534842 function CopyButton(props: {
535843 text: string;
536844 field: string;