| @@ -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; |