History

nicer ui

Rhys Sullivan committed 8 months ago

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

Showing the changes to apps/web/src (7 of 14 files). Show all

Unified · Split

7 files
  1. apps/web/src/components/document-panel.tsx +2 −13
  2. apps/web/src/components/icons.tsx +10 −0
  3. apps/web/src/components/markdown.tsx +53 −0
  4. apps/web/src/components/shell.tsx +9 −109
  5. apps/web/src/globals.css +10 −0
  6. apps/web/src/lib/format.ts +133 −0
  7. apps/web/src/views/source-detail.tsx +330 −22
apps/web/src/components/document-panel.tsx+2−13
@@ -1,15 +1,7 @@
1import { useMemo } from "react";
2import { cn } from "../lib/utils";1import { cn } from "../lib/utils";
2import { useFormattedCode } from "../lib/format";
3import { CodeBlock } from "./code-block";3import { CodeBlock } from "./code-block";
4 4
5function prettyJson(raw: string): string {
6 try {
7 return JSON.stringify(JSON.parse(raw), null, 2);
8 } catch {
9 return raw;
10 }
11}
12
13export function DocumentPanel(props: {5export 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 <section15 <section
apps/web/src/components/icons.tsx+10−0
@@ -107,3 +107,13 @@ export function IconSpinner({ className, ...props }: IconProps) { · 106 unmodified lines
107 </svg>107 </svg>
108 );108 );
109}109}
110
111export 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 @@
1import { Streamdown } from "streamdown";
2import { createCodePlugin } from "@streamdown/code";
3
4const codePlugin = createCodePlugin({
5 themes: ["vitesse-dark", "vitesse-dark"],
6});
7
8const 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
45export 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 @@
1import { useState, useMemo } from "react";
2import { Link, Outlet, useMatchRoute } from "@tanstack/react-router";1import { Link, Outlet, useMatchRoute } from "@tanstack/react-router";
3import { useSources, type Source } from "@executor-v3/react";2import { useSources, type Source } from "@executor-v3/react";
4import { cn } from "../lib/utils";3import { cn } from "../lib/utils";
5import { IconSources, IconChevron } from "./icons";4import { IconSources } from "./icons";
6import { LoadableBlock } from "./loadable";5import { LoadableBlock } from "./loadable";
7 6
8// ── Kind labels + ordering ───────────────────────────────────────────────
9
10const 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
17function 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
31const statusColor: Record<string, string> = {9const 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 Sources46 Sources
69 </div>47 </div>
@@ -74,7 +52,11 @@ export function AppShell() { · 4 unmodified lines
74 No sources yet52 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
101function 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
129function 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
174function SourceItem(props: {83function 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 active101 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 <span108 <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}
apps/web/src/globals.css+10−0
@@ -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
apps/web/src/lib/format.ts+133−0
@@ -0,0 +1,133 @@
1import { useState, useEffect } from "react";
2
3// Cache formatted results so we don't re-format on every render
4const formatCache = new Map<string, string>();
5
6function 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 */
14async 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 */
76function 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 */
93export 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 @@
1import { useState, useEffect, useCallback, useRef } from "react";1import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2import { useNavigate } from "@tanstack/react-router";2import { useNavigate } from "@tanstack/react-router";
3import {3import {
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";
29import { Markdown } from "../components/markdown";
26 30
27// ---------------------------------------------------------------------------31// ---------------------------------------------------------------------------
28// Types32// 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 <ToolListItem262 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
297type ToolTreeNode = {
298 segment: string;
299 tool?: SourceInspection["tools"][number];
300 children: Map<string, ToolTreeNode>;
301};
302
303function 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
319function 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
352function 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
494function 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// ToolListItem503// 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 <button551 <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.active557 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// ToolDetailPanel574// ToolDetailPanel
329// ---------------------------------------------------------------------------575// ---------------------------------------------------------------------------
330 576
577type OutputTypeExplanation = {
578 title: string;
579 description: string;
580};
581
582const 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
331function ToolDetailPanel(props: { detail: SourceInspectionToolDetail }) {601function 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
828function 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
534function CopyButton(props: {842function CopyButton(props: {
535 text: string;843 text: string;
536 field: string;844 field: string;