@igstack/app-catalog-frontend-core 1.0.0-alpha-20260913172344 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js +1 -1
  2. package/dist/esm/modules/appCatalog/ui/catalog/AppCatalogGrid.js.map +1 -1
  3. package/dist/esm/modules/appCatalog/ui/components/Highlight.js +1 -1
  4. package/dist/esm/modules/appCatalog/ui/components/Highlight.js.map +1 -1
  5. package/dist/esm/modules/appCatalog/ui/components/ViewToggle.d.ts +1 -1
  6. package/dist/esm/modules/appCatalog/ui/components/ViewToggle.js +11 -0
  7. package/dist/esm/modules/appCatalog/ui/components/ViewToggle.js.map +1 -1
  8. package/dist/esm/modules/appCatalog/ui/pages/McpPage.d.ts +11 -0
  9. package/dist/esm/modules/appCatalog/ui/pages/McpPage.js +227 -0
  10. package/dist/esm/modules/appCatalog/ui/pages/McpPage.js.map +1 -0
  11. package/dist/esm/modules/appCatalog/utils/fetchMcpTools.d.ts +32 -0
  12. package/dist/esm/modules/appCatalog/utils/fetchMcpTools.js +79 -0
  13. package/dist/esm/modules/appCatalog/utils/fetchMcpTools.js.map +1 -0
  14. package/dist/esm/modules/appCatalog/utils/highlightText.d.ts +15 -0
  15. package/dist/esm/modules/appCatalog/utils/highlightText.js +30 -0
  16. package/dist/esm/modules/appCatalog/utils/highlightText.js.map +1 -0
  17. package/dist/esm/routeTree.gen.d.ts +18 -3
  18. package/dist/esm/routeTree.gen.js +9 -2
  19. package/dist/esm/routeTree.gen.js.map +1 -1
  20. package/dist/esm/routes/_layout/mcp.d.ts +3 -0
  21. package/dist/esm/routes/_layout/mcp.js +20 -0
  22. package/dist/esm/routes/_layout/mcp.js.map +1 -0
  23. package/package.json +5 -5
  24. package/src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx +1 -1
  25. package/src/modules/appCatalog/ui/components/Highlight.tsx +1 -1
  26. package/src/modules/appCatalog/ui/components/ViewToggle.tsx +10 -1
  27. package/src/modules/appCatalog/ui/pages/McpPage.tsx +275 -0
  28. package/src/modules/appCatalog/utils/fetchMcpTools.ts +140 -0
  29. package/src/modules/appCatalog/utils/highlightText.ts +51 -0
  30. package/src/routeTree.gen.ts +21 -0
  31. package/src/routes/_layout/mcp.tsx +22 -0
  32. package/dist/esm/__tests__/modules/appCatalog/utils/searchApps.test.d.ts +0 -1
  33. package/dist/esm/modules/appCatalog/utils/searchApps.d.ts +0 -49
  34. package/dist/esm/modules/appCatalog/utils/searchApps.js +0 -144
  35. package/dist/esm/modules/appCatalog/utils/searchApps.js.map +0 -1
  36. package/src/__tests__/modules/appCatalog/utils/searchApps.test.ts +0 -96
  37. package/src/modules/appCatalog/utils/searchApps.ts +0 -266
@@ -7,7 +7,7 @@ import { markdownToPlainText } from "../../utils/markdownToPlainText.js";
7
7
  import { pickNewThisWeek } from "../../utils/launcherHelpers.js";
8
8
  import { AttributionFooter } from "./AttributionFooter.js";
9
9
  import { ResourceIcon } from "./ResourceIcon.js";
10
- import { searchResources } from "../../utils/searchApps.js";
10
+ import { searchResources } from "@igstack/app-catalog-shared-core";
11
11
  import { Highlight } from "../components/Highlight.js";
12
12
  const typeLabel = (t) => {
13
13
  if (!t || t === "application") return null;
@@ -1 +1 @@
1
- {"version":3,"file":"AppCatalogGrid.js","sources":["../../../../../../src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ArrowUpRight, Search, X } from 'lucide-react'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { cn } from '~/lib/utils'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { markdownToPlainText } from '../../utils/markdownToPlainText'\nimport { pickNewThisWeek } from '../../utils/launcherHelpers'\nimport { AttributionFooter } from './AttributionFooter'\nimport { ResourceIcon } from './ResourceIcon'\nimport { searchResources } from '../../utils/searchApps'\nimport { Highlight } from '../components/Highlight'\n\n/**\n * Adaptive-home discovery spine (issue #38, increment 1) — matches the\n * option-a-launcher prototype:\n * Your apps (frequent) → New this week → Browse all\n *\n * This is the PERSISTENT shell for the catalog: idle browse, active search and\n * an open detail overlay all render inside it. Only the area below the hero\n * swaps (discovery spine ↔ results list), so the hero copy, the search box and\n * the page container never move while the user types. Do not switch the page to\n * a different container on search — that was the layout-jitter bug.\n */\n\n// Type pill text. Plain \"application\" resources are the common case and don't\n// need a badge (the user asked not to show a bare \"App\" badge); only surface a\n// pill for distinctive types (database, cloud, service, …).\nconst typeLabel = (t?: string): string | null => {\n if (!t || t === 'application') return null\n return t.charAt(0).toUpperCase() + t.slice(1)\n}\n\nexport interface AppCatalogGridProps {\n /** All root (non-child) resources, already filtered for deprecated per settings. */\n apps: Resource[]\n /**\n * All resources INCLUDING children/sub-resources. Passed to search so a query\n * matching a sub-resource (e.g. an AWS account, an alias/account ID) surfaces\n * its parent — preserving the existing cross-sub-resource search behavior.\n * Falls back to `apps` when omitted.\n */\n allResources?: Resource[]\n searchValue: string\n onSearchChange: (v: string) => void\n onAppClick: (app: Resource) => void\n /** Opens a matched sub-resource: its parent's detail, singled out to it. */\n onSubClick: (parentSlug: string, subSlug: string) => void\n onLaunch: (app: Resource) => void\n /** Total resource count for the \"Browse all\" label. */\n totalCount: number\n /**\n * True while an app/sub-resource detail overlay is open above the launcher.\n * The results list then stops binding ↑↓/↵/Esc so the overlay owns the\n * keyboard (otherwise Esc would ALSO wipe the search behind the overlay).\n */\n detailOpen?: boolean\n /** Currently open sub-resource slug, for highlighting its row. */\n selectedSubSlug?: string\n}\n\nfunction LaunchButton({\n app,\n onLaunch,\n className,\n}: {\n app: Resource\n onLaunch: (app: Resource) => void\n className?: string\n}) {\n if (!app.appUrl) return null\n // Show the destination URL on hover (user ask) — the native title tooltip\n // reveals where the ↗ jumps to, e.g. \"Open → console.aws.amazon.com\".\n const prettyUrl = app.appUrl.replace(/^https?:\\/\\//, '')\n return (\n <a\n href={app.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n title={`Open → ${prettyUrl}`}\n aria-label={`Open ${app.displayName} in a new tab (${prettyUrl})`}\n onClick={(e) => {\n e.stopPropagation()\n onLaunch(app)\n }}\n className={cn(\n 'grid place-items-center rounded-full border border-border bg-card text-muted-foreground',\n 'hover:bg-primary hover:text-primary-foreground hover:border-primary transition-colors',\n className,\n )}\n >\n <ArrowUpRight className=\"size-3.5\" />\n </a>\n )\n}\n\nfunction SectionHead({\n title,\n count,\n action,\n}: {\n title: string\n count?: string\n action?: React.ReactNode\n}) {\n return (\n <div className=\"flex items-center gap-2.5 mb-4\">\n <h2 className=\"font-serif font-semibold text-[15px] tracking-tight m-0\">\n {title}\n </h2>\n {count && (\n <span className=\"text-[13px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5\">\n {count}\n </span>\n )}\n {action && <div className=\"ml-auto\">{action}</div>}\n </div>\n )\n}\n\n/** Small row used by \"New this week\" and \"Browse all\". */\nfunction ResourceRow({\n app,\n onAppClick,\n onLaunch,\n showNew = false,\n}: {\n app: Resource\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n showNew?: boolean\n}) {\n return (\n <button\n type=\"button\"\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border border-border rounded-[var(--radius)] px-3.5 py-3',\n 'hover:border-ring hover:shadow-sm transition-all',\n )}\n >\n <ResourceIcon app={app} size={38} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14.5px] font-semibold truncate\">\n {app.displayName}\n </span>\n {app.description && (\n <span className=\"block text-[13px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n </span>\n {showNew && (\n <span className=\"text-[11px] font-bold text-[#65a443] bg-[#65a443]/12 rounded-full px-2.5 py-0.5\">\n New\n </span>\n )}\n {typeLabel(app.type) && (\n <span className=\"text-[12px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton app={app} onLaunch={onLaunch} className=\"size-8\" />\n </button>\n )\n}\n\n/** Matched sub-resources listed under a parent before the \"… N more\" row. */\nconst COLLAPSED_SUB_LIMIT = 5\n\n/** One line in the flat, keyboard-navigable results list. */\ntype ResultRow =\n | { kind: 'app'; key: string; app: Resource }\n | { kind: 'sub'; key: string; sub: Resource; parent: Resource }\n | { kind: 'more'; key: string; parent: Resource; hidden: number }\n\n/**\n * Keyboard-navigable search results list — the launcher's only search surface.\n *\n * It renders directly BELOW the hero search box, inside the same persistent\n * launcher shell: the hero copy, the search input and the page container never\n * move when the user types, and no filter chrome (tabs / category / deprecated\n * toggle) appears. Those filters live in the browse view only — clearing the\n * search brings them back.\n *\n * Rows are flat and individually clickable. A matched sub-resource gets its own\n * indented row under its parent, so a query that only hits a sub-resource\n * (e.g. a cloud account) can be opened directly instead of forcing the user to\n * dig through the parent's detail. ↑↓ moves over every row, ↵ activates the\n * focused one, Esc clears the search.\n */\nfunction SearchResultsList({\n apps,\n searchValue,\n onAppClick,\n onSubClick,\n onLaunch,\n onClear,\n keyboardEnabled = true,\n selectedSubSlug,\n}: {\n apps: Resource[]\n searchValue: string\n onAppClick: (app: Resource) => void\n onSubClick: (parentSlug: string, subSlug: string) => void\n onLaunch: (app: Resource) => void\n onClear: () => void\n keyboardEnabled?: boolean\n selectedSubSlug?: string\n}) {\n const [focusedIndex, setFocusedIndex] = useState(-1)\n const [expandedParents, setExpandedParents] = useState<Set<string>>(\n () => new Set(),\n )\n\n const results = useMemo(\n () => searchResources(apps, searchValue),\n [apps, searchValue],\n )\n\n // For each result, the child/sub-resources of it that themselves match the\n // query — so a parent surfaced only via its children (e.g. AWS Console via a\n // \"biom\" account) tells the user WHICH sub-resources matched, instead of an\n // unexplained parent. Keyed by parent slug.\n const matchedChildrenByParent = useMemo(() => {\n const q = searchValue.trim().toLowerCase()\n const map = new Map<string, Resource[]>()\n if (!q) return map\n const resultSlugs = new Set(results.map((r) => r.slug))\n for (const r of apps) {\n if (!r.parentSlug || !resultSlugs.has(r.parentSlug)) continue\n const hay = `${r.displayName} ${r.abbreviation ?? ''} ${(\n r.aliases ?? []\n ).join(' ')} ${r.description ?? ''}`.toLowerCase()\n if (!hay.includes(q)) continue\n const list = map.get(r.parentSlug) ?? []\n list.push(r)\n map.set(r.parentSlug, list)\n }\n return map\n }, [apps, results, searchValue])\n\n // #11 deprecated fallback: when EVERY match is a deprecated app (i.e. there\n // are no active matches for the query, only deprecated ones), surface a\n // notice so the user understands why they're seeing archived tools. The\n // deprecated apps are already in `results` because the launcher feeds search\n // the full resource set; this just labels the situation.\n const didDeprecatedFallback = useMemo(\n () => results.length > 0 && results.every((r) => Boolean(r.deprecated)),\n [results],\n )\n\n // Flatten parents + their matched sub-resources into one navigable list, so a\n // sub-resource row is a first-class result (clickable, focusable) rather than\n // a footnote on its parent.\n const rows = useMemo(() => {\n const out: ResultRow[] = []\n for (const app of results) {\n out.push({ kind: 'app', key: `app:${app.slug}`, app })\n const kids = matchedChildrenByParent.get(app.slug) ?? []\n const expanded = expandedParents.has(app.slug)\n const visible = expanded ? kids : kids.slice(0, COLLAPSED_SUB_LIMIT)\n for (const sub of visible) {\n out.push({\n kind: 'sub',\n key: `sub:${app.slug}:${sub.slug}`,\n sub,\n parent: app,\n })\n }\n const hidden = kids.length - visible.length\n if (hidden > 0) {\n out.push({ kind: 'more', key: `more:${app.slug}`, parent: app, hidden })\n }\n }\n return out\n }, [results, matchedChildrenByParent, expandedParents])\n\n const matchCount = useMemo(() => {\n let n = results.length\n for (const kids of matchedChildrenByParent.values()) n += kids.length\n return n\n }, [results, matchedChildrenByParent])\n\n const activateRow = useCallback(\n (row: ResultRow) => {\n if (row.kind === 'app') {\n onAppClick(row.app)\n } else if (row.kind === 'sub') {\n // Open the parent's detail singled out to THIS sub-resource. Filtering\n // the parent's sub-resource table by the query instead would leave the\n // clicked row buried among every other match (\"project\" matches 47).\n onSubClick(row.parent.slug, row.sub.slug)\n } else {\n setExpandedParents((prev) => new Set(prev).add(row.parent.slug))\n }\n },\n [onAppClick, onSubClick],\n )\n\n // Reset focus and collapse expanded sub-resource lists when the query changes\n useEffect(() => {\n setFocusedIndex(-1)\n setExpandedParents(new Set())\n }, [searchValue])\n\n useEffect(() => {\n // While a detail overlay is open it owns the keyboard: without this guard\n // Esc would clear the search behind the overlay and ↑↓/↵ would navigate to\n // a different app from a list the user cannot see.\n if (!keyboardEnabled) return\n const onKey = (e: KeyboardEvent) => {\n if (rows.length === 0) return\n if (e.key === 'ArrowDown') {\n e.preventDefault()\n setFocusedIndex((i) => Math.min(i + 1, rows.length - 1))\n } else if (e.key === 'ArrowUp') {\n e.preventDefault()\n setFocusedIndex((i) => Math.max(i - 1, 0))\n } else if (e.key === 'Enter') {\n const row = focusedIndex >= 0 ? rows[focusedIndex] : undefined\n if (row) activateRow(row)\n } else if (e.key === 'Escape') {\n onClear()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [rows, focusedIndex, activateRow, onClear, keyboardEnabled])\n\n return (\n <div className=\"max-w-[620px] mx-auto mt-3\">\n {/* Meta line. Counts matched sub-resources too — they are results in\n their own right, and a parent surfaced only via 47 matching accounts\n used to be reported as \"1 result\". Collapsed ones still count, so the\n number doesn't jump when the \"... N more\" row is expanded. */}\n <div className=\"text-[13px] text-muted-foreground mb-2 px-1\">\n {matchCount === 0\n ? `No results for \"${searchValue}\"`\n : `${matchCount} result${matchCount === 1 ? '' : 's'}`}\n </div>\n\n {/* #11: deprecated-only fallback notice */}\n {didDeprecatedFallback && (\n <div\n role=\"status\"\n className=\"text-[13px] text-muted-foreground mb-2 px-1\"\n >\n No active apps for &quot;{searchValue}&quot; — showing deprecated\n matches.\n </div>\n )}\n\n {/* Results — parents and their matched sub-resources as one flat list */}\n <div\n className=\"flex flex-col gap-1.5\"\n role=\"listbox\"\n aria-label=\"Search results\"\n >\n {rows.map((row, i) => {\n const focused = focusedIndex === i\n\n if (row.kind === 'sub') {\n const { sub, parent } = row\n const isOpen = selectedSubSlug === sub.slug\n return (\n <button\n key={row.key}\n type=\"button\"\n role=\"option\"\n data-kind=\"sub\"\n aria-selected={focused}\n // \"Which one am I looking at\" — set while this sub-resource is\n // the one singled out in its parent's detail behind the overlay.\n aria-current={isOpen ? 'true' : undefined}\n aria-label={`${sub.displayName} — sub-resource of ${parent.displayName}`}\n title={sub.displayName}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => activateRow(row)}\n className={cn(\n // Indented + left rule so the parent/child relation reads at\n // a glance without an extra text prefix.\n 'flex items-center ml-8 border-l-2 pl-3 pr-3 py-1.5 text-left',\n 'text-[13px] rounded-r-[var(--radius)] transition-colors',\n focused || isOpen\n ? 'border-primary bg-muted/40 text-foreground'\n : 'border-border text-muted-foreground hover:border-primary hover:bg-muted/30 hover:text-foreground',\n )}\n >\n <Highlight text={sub.displayName} query={searchValue} />\n </button>\n )\n }\n\n if (row.kind === 'more') {\n return (\n <button\n key={row.key}\n type=\"button\"\n role=\"option\"\n data-kind=\"more\"\n aria-selected={focused}\n aria-label={`Show ${row.hidden} more matching sub-resources of ${row.parent.displayName}`}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => activateRow(row)}\n className={cn(\n 'flex items-center ml-8 border-l-2 pl-3 pr-3 py-1.5 text-left',\n 'text-[13px] rounded-r-[var(--radius)] transition-colors',\n focused\n ? 'border-primary bg-muted/40 text-foreground'\n : 'border-border text-muted-foreground hover:border-primary hover:text-foreground',\n )}\n >\n ... {row.hidden} more\n </button>\n )\n }\n\n const app = row.app\n return (\n <button\n key={row.key}\n type=\"button\"\n role=\"option\"\n title={`View ${app.displayName}`}\n aria-selected={focused}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => activateRow(row)}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border rounded-[var(--radius)] px-3.5 py-2.5',\n 'transition-all',\n focused\n ? 'border-ring shadow-sm bg-muted/30'\n : 'border-border hover:border-ring hover:shadow-sm',\n )}\n >\n <ResourceIcon app={app} size={40} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14px] font-semibold truncate\">\n <Highlight text={app.displayName} query={searchValue} />\n {app.abbreviation && app.abbreviation !== app.displayName && (\n <span className=\"ml-1.5 text-[12px] font-normal text-muted-foreground\">\n (<Highlight text={app.abbreviation} query={searchValue} />\n )\n </span>\n )}\n </span>\n {app.description && (\n <span className=\"block text-[12.5px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n </span>\n {typeLabel(app.type) && (\n <span className=\"text-[11.5px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap shrink-0\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"size-7 shrink-0 opacity-0 group-hover:opacity-100\"\n />\n </button>\n )\n })}\n </div>\n\n {/* Keyboard hint */}\n {rows.length > 0 && (\n <p className=\"text-[11.5px] text-muted-foreground/60 text-center mt-3\">\n ↑↓ navigate · ↵ view access · esc clear\n </p>\n )}\n </div>\n )\n}\n\nexport function AppCatalogGrid({\n apps,\n allResources,\n searchValue,\n onSearchChange,\n onAppClick,\n onSubClick,\n onLaunch,\n totalCount,\n detailOpen = false,\n selectedSubSlug,\n}: AppCatalogGridProps) {\n const { getTopApps } = useAppClickHistory()\n const [topSlugs, setTopSlugs] = useState<string[]>([])\n\n useEffect(() => {\n void getTopApps(5).then(setTopSlugs)\n }, [getTopApps])\n\n const bySlug = useMemo(() => {\n const m = new Map<string, Resource>()\n for (const a of apps) m.set(a.slug, a)\n return m\n }, [apps])\n\n // Your apps: frequently-clicked (personalized). Empty on a cold new machine.\n const frequent = useMemo(\n () =>\n topSlugs\n .map((s) => bySlug.get(s))\n .filter((a): a is Resource => Boolean(a))\n .slice(0, 5),\n [topSlugs, bySlug],\n )\n\n // New this week: apps ADDED in the last 7 days — see pickNewThisWeek (#96).\n const fresh = useMemo(() => pickNewThisWeek(apps), [apps])\n\n // Browse all: everything not already promoted to \"Your apps\", alpha by name.\n const browse = useMemo(() => {\n const promoted = new Set(frequent.map((a) => a.slug))\n return [...apps]\n .filter((a) => !promoted.has(a.slug))\n .sort((a, b) => a.displayName.localeCompare(b.displayName))\n }, [apps, frequent])\n\n const browseLeft = browse.filter((_, i) => i % 2 === 0)\n const browseRight = browse.filter((_, i) => i % 2 === 1)\n\n const isSearching = searchValue.trim() !== ''\n const searchRef = useRef<HTMLInputElement>(null)\n\n // ⌘K / Ctrl+K focuses the hero search box from anywhere on the page.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault()\n searchRef.current?.focus()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [])\n\n return (\n <div className=\"mx-auto w-full max-w-[1000px] pb-24\">\n {/* hero */}\n <div className=\"text-center pt-10 pb-1.5\">\n <h1 className=\"font-serif font-semibold text-[30px] tracking-tight m-0 mb-1.5\">\n What do you need to get into?\n </h1>\n <p className=\"text-muted-foreground m-0 mb-6 text-[15px]\">\n Find any tool, see how to request access, or jump straight in.\n </p>\n <div className=\"max-w-[620px] mx-auto\">\n <div className=\"flex items-center gap-3 bg-card border-[1.5px] border-input rounded-full px-5 h-14 shadow-sm focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/15 transition-colors\">\n <Search className=\"size-5 shrink-0 text-muted-foreground\" />\n <input\n type=\"text\"\n value={searchValue}\n onChange={(e) => onSearchChange(e.target.value)}\n placeholder=\"Search apps, databases, cloud accounts, account IDs…\"\n autoComplete=\"off\"\n autoFocus\n ref={searchRef}\n aria-label=\"Search apps\"\n className=\"flex-1 bg-transparent border-0 outline-none text-[16px] text-foreground placeholder:text-muted-foreground\"\n />\n {isSearching ? (\n <button\n type=\"button\"\n aria-label=\"Clear search\"\n onClick={() => {\n onSearchChange('')\n searchRef.current?.focus()\n }}\n className=\"shrink-0 rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n <X className=\"size-4\" />\n </button>\n ) : (\n <kbd className=\"hidden sm:inline-flex items-center gap-0.5 text-[11px] text-muted-foreground/60 border border-muted-foreground/20 rounded px-1.5 py-0.5 font-mono shrink-0\">\n {typeof navigator !== 'undefined' &&\n navigator.platform.includes('Mac')\n ? '⌘K'\n : 'Ctrl+K'}\n </kbd>\n )}\n </div>\n </div>\n </div>\n\n {/* Search results: rendered in place, directly under the (unmoved) hero\n search box. The discovery spine below is what gets replaced — the\n shell, hero and search box stay put, so typing never shifts layout. */}\n {isSearching && (\n <SearchResultsList\n apps={allResources ?? apps}\n searchValue={searchValue}\n onAppClick={onAppClick}\n onSubClick={onSubClick}\n onLaunch={onLaunch}\n onClear={() => onSearchChange('')}\n keyboardEnabled={!detailOpen}\n selectedSubSlug={selectedSubSlug}\n />\n )}\n\n {/* Discovery spine: only shown when not searching */}\n {!isSearching && (\n <>\n {/* Your apps */}\n {frequent.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead title=\"Your apps\" count=\"frequent\" />\n <div className=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3.5\">\n {frequent.map((app) => (\n <button\n type=\"button\"\n key={app.slug}\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className=\"group relative bg-card border border-border rounded-[var(--radius)] px-3.5 pt-[18px] pb-4 text-center shadow-sm hover:-translate-y-0.5 hover:shadow-md transition-all\"\n >\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"absolute top-2.5 right-2.5 size-[26px] opacity-0 group-hover:opacity-100\"\n />\n <ResourceIcon\n app={app}\n size={46}\n className=\"mx-auto mb-3\"\n />\n <span className=\"block text-[13.5px] font-bold truncate\">\n {app.displayName}\n </span>\n </button>\n ))}\n </div>\n </section>\n )}\n\n {/* New this week */}\n {fresh.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead\n title=\"New this week\"\n count={`${fresh.length} added`}\n />\n <div className=\"flex flex-col gap-2\">\n {fresh.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n showNew\n />\n ))}\n </div>\n </section>\n )}\n\n {/* Browse all */}\n <section className=\"mt-9\">\n <SectionHead title=\"Browse all\" count={`${totalCount} resources`} />\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-2\">\n <div className=\"flex flex-col gap-2\">\n {browseLeft.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </div>\n <div className=\"flex flex-col gap-2\">\n {browseRight.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </div>\n </div>\n </section>\n\n <AttributionFooter />\n </>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AppCatalogGrid.js","sources":["../../../../../../src/modules/appCatalog/ui/catalog/AppCatalogGrid.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ArrowUpRight, Search, X } from 'lucide-react'\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react'\nimport { cn } from '~/lib/utils'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { markdownToPlainText } from '../../utils/markdownToPlainText'\nimport { pickNewThisWeek } from '../../utils/launcherHelpers'\nimport { AttributionFooter } from './AttributionFooter'\nimport { ResourceIcon } from './ResourceIcon'\nimport { searchResources } from '@igstack/app-catalog-shared-core'\nimport { Highlight } from '../components/Highlight'\n\n/**\n * Adaptive-home discovery spine (issue #38, increment 1) — matches the\n * option-a-launcher prototype:\n * Your apps (frequent) → New this week → Browse all\n *\n * This is the PERSISTENT shell for the catalog: idle browse, active search and\n * an open detail overlay all render inside it. Only the area below the hero\n * swaps (discovery spine ↔ results list), so the hero copy, the search box and\n * the page container never move while the user types. Do not switch the page to\n * a different container on search — that was the layout-jitter bug.\n */\n\n// Type pill text. Plain \"application\" resources are the common case and don't\n// need a badge (the user asked not to show a bare \"App\" badge); only surface a\n// pill for distinctive types (database, cloud, service, …).\nconst typeLabel = (t?: string): string | null => {\n if (!t || t === 'application') return null\n return t.charAt(0).toUpperCase() + t.slice(1)\n}\n\nexport interface AppCatalogGridProps {\n /** All root (non-child) resources, already filtered for deprecated per settings. */\n apps: Resource[]\n /**\n * All resources INCLUDING children/sub-resources. Passed to search so a query\n * matching a sub-resource (e.g. an AWS account, an alias/account ID) surfaces\n * its parent — preserving the existing cross-sub-resource search behavior.\n * Falls back to `apps` when omitted.\n */\n allResources?: Resource[]\n searchValue: string\n onSearchChange: (v: string) => void\n onAppClick: (app: Resource) => void\n /** Opens a matched sub-resource: its parent's detail, singled out to it. */\n onSubClick: (parentSlug: string, subSlug: string) => void\n onLaunch: (app: Resource) => void\n /** Total resource count for the \"Browse all\" label. */\n totalCount: number\n /**\n * True while an app/sub-resource detail overlay is open above the launcher.\n * The results list then stops binding ↑↓/↵/Esc so the overlay owns the\n * keyboard (otherwise Esc would ALSO wipe the search behind the overlay).\n */\n detailOpen?: boolean\n /** Currently open sub-resource slug, for highlighting its row. */\n selectedSubSlug?: string\n}\n\nfunction LaunchButton({\n app,\n onLaunch,\n className,\n}: {\n app: Resource\n onLaunch: (app: Resource) => void\n className?: string\n}) {\n if (!app.appUrl) return null\n // Show the destination URL on hover (user ask) — the native title tooltip\n // reveals where the ↗ jumps to, e.g. \"Open → console.aws.amazon.com\".\n const prettyUrl = app.appUrl.replace(/^https?:\\/\\//, '')\n return (\n <a\n href={app.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n title={`Open → ${prettyUrl}`}\n aria-label={`Open ${app.displayName} in a new tab (${prettyUrl})`}\n onClick={(e) => {\n e.stopPropagation()\n onLaunch(app)\n }}\n className={cn(\n 'grid place-items-center rounded-full border border-border bg-card text-muted-foreground',\n 'hover:bg-primary hover:text-primary-foreground hover:border-primary transition-colors',\n className,\n )}\n >\n <ArrowUpRight className=\"size-3.5\" />\n </a>\n )\n}\n\nfunction SectionHead({\n title,\n count,\n action,\n}: {\n title: string\n count?: string\n action?: React.ReactNode\n}) {\n return (\n <div className=\"flex items-center gap-2.5 mb-4\">\n <h2 className=\"font-serif font-semibold text-[15px] tracking-tight m-0\">\n {title}\n </h2>\n {count && (\n <span className=\"text-[13px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5\">\n {count}\n </span>\n )}\n {action && <div className=\"ml-auto\">{action}</div>}\n </div>\n )\n}\n\n/** Small row used by \"New this week\" and \"Browse all\". */\nfunction ResourceRow({\n app,\n onAppClick,\n onLaunch,\n showNew = false,\n}: {\n app: Resource\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n showNew?: boolean\n}) {\n return (\n <button\n type=\"button\"\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border border-border rounded-[var(--radius)] px-3.5 py-3',\n 'hover:border-ring hover:shadow-sm transition-all',\n )}\n >\n <ResourceIcon app={app} size={38} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14.5px] font-semibold truncate\">\n {app.displayName}\n </span>\n {app.description && (\n <span className=\"block text-[13px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n </span>\n {showNew && (\n <span className=\"text-[11px] font-bold text-[#65a443] bg-[#65a443]/12 rounded-full px-2.5 py-0.5\">\n New\n </span>\n )}\n {typeLabel(app.type) && (\n <span className=\"text-[12px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton app={app} onLaunch={onLaunch} className=\"size-8\" />\n </button>\n )\n}\n\n/** Matched sub-resources listed under a parent before the \"… N more\" row. */\nconst COLLAPSED_SUB_LIMIT = 5\n\n/** One line in the flat, keyboard-navigable results list. */\ntype ResultRow =\n | { kind: 'app'; key: string; app: Resource }\n | { kind: 'sub'; key: string; sub: Resource; parent: Resource }\n | { kind: 'more'; key: string; parent: Resource; hidden: number }\n\n/**\n * Keyboard-navigable search results list — the launcher's only search surface.\n *\n * It renders directly BELOW the hero search box, inside the same persistent\n * launcher shell: the hero copy, the search input and the page container never\n * move when the user types, and no filter chrome (tabs / category / deprecated\n * toggle) appears. Those filters live in the browse view only — clearing the\n * search brings them back.\n *\n * Rows are flat and individually clickable. A matched sub-resource gets its own\n * indented row under its parent, so a query that only hits a sub-resource\n * (e.g. a cloud account) can be opened directly instead of forcing the user to\n * dig through the parent's detail. ↑↓ moves over every row, ↵ activates the\n * focused one, Esc clears the search.\n */\nfunction SearchResultsList({\n apps,\n searchValue,\n onAppClick,\n onSubClick,\n onLaunch,\n onClear,\n keyboardEnabled = true,\n selectedSubSlug,\n}: {\n apps: Resource[]\n searchValue: string\n onAppClick: (app: Resource) => void\n onSubClick: (parentSlug: string, subSlug: string) => void\n onLaunch: (app: Resource) => void\n onClear: () => void\n keyboardEnabled?: boolean\n selectedSubSlug?: string\n}) {\n const [focusedIndex, setFocusedIndex] = useState(-1)\n const [expandedParents, setExpandedParents] = useState<Set<string>>(\n () => new Set(),\n )\n\n const results = useMemo(\n () => searchResources(apps, searchValue),\n [apps, searchValue],\n )\n\n // For each result, the child/sub-resources of it that themselves match the\n // query — so a parent surfaced only via its children (e.g. AWS Console via a\n // \"biom\" account) tells the user WHICH sub-resources matched, instead of an\n // unexplained parent. Keyed by parent slug.\n const matchedChildrenByParent = useMemo(() => {\n const q = searchValue.trim().toLowerCase()\n const map = new Map<string, Resource[]>()\n if (!q) return map\n const resultSlugs = new Set(results.map((r) => r.slug))\n for (const r of apps) {\n if (!r.parentSlug || !resultSlugs.has(r.parentSlug)) continue\n const hay = `${r.displayName} ${r.abbreviation ?? ''} ${(\n r.aliases ?? []\n ).join(' ')} ${r.description ?? ''}`.toLowerCase()\n if (!hay.includes(q)) continue\n const list = map.get(r.parentSlug) ?? []\n list.push(r)\n map.set(r.parentSlug, list)\n }\n return map\n }, [apps, results, searchValue])\n\n // #11 deprecated fallback: when EVERY match is a deprecated app (i.e. there\n // are no active matches for the query, only deprecated ones), surface a\n // notice so the user understands why they're seeing archived tools. The\n // deprecated apps are already in `results` because the launcher feeds search\n // the full resource set; this just labels the situation.\n const didDeprecatedFallback = useMemo(\n () => results.length > 0 && results.every((r) => Boolean(r.deprecated)),\n [results],\n )\n\n // Flatten parents + their matched sub-resources into one navigable list, so a\n // sub-resource row is a first-class result (clickable, focusable) rather than\n // a footnote on its parent.\n const rows = useMemo(() => {\n const out: ResultRow[] = []\n for (const app of results) {\n out.push({ kind: 'app', key: `app:${app.slug}`, app })\n const kids = matchedChildrenByParent.get(app.slug) ?? []\n const expanded = expandedParents.has(app.slug)\n const visible = expanded ? kids : kids.slice(0, COLLAPSED_SUB_LIMIT)\n for (const sub of visible) {\n out.push({\n kind: 'sub',\n key: `sub:${app.slug}:${sub.slug}`,\n sub,\n parent: app,\n })\n }\n const hidden = kids.length - visible.length\n if (hidden > 0) {\n out.push({ kind: 'more', key: `more:${app.slug}`, parent: app, hidden })\n }\n }\n return out\n }, [results, matchedChildrenByParent, expandedParents])\n\n const matchCount = useMemo(() => {\n let n = results.length\n for (const kids of matchedChildrenByParent.values()) n += kids.length\n return n\n }, [results, matchedChildrenByParent])\n\n const activateRow = useCallback(\n (row: ResultRow) => {\n if (row.kind === 'app') {\n onAppClick(row.app)\n } else if (row.kind === 'sub') {\n // Open the parent's detail singled out to THIS sub-resource. Filtering\n // the parent's sub-resource table by the query instead would leave the\n // clicked row buried among every other match (\"project\" matches 47).\n onSubClick(row.parent.slug, row.sub.slug)\n } else {\n setExpandedParents((prev) => new Set(prev).add(row.parent.slug))\n }\n },\n [onAppClick, onSubClick],\n )\n\n // Reset focus and collapse expanded sub-resource lists when the query changes\n useEffect(() => {\n setFocusedIndex(-1)\n setExpandedParents(new Set())\n }, [searchValue])\n\n useEffect(() => {\n // While a detail overlay is open it owns the keyboard: without this guard\n // Esc would clear the search behind the overlay and ↑↓/↵ would navigate to\n // a different app from a list the user cannot see.\n if (!keyboardEnabled) return\n const onKey = (e: KeyboardEvent) => {\n if (rows.length === 0) return\n if (e.key === 'ArrowDown') {\n e.preventDefault()\n setFocusedIndex((i) => Math.min(i + 1, rows.length - 1))\n } else if (e.key === 'ArrowUp') {\n e.preventDefault()\n setFocusedIndex((i) => Math.max(i - 1, 0))\n } else if (e.key === 'Enter') {\n const row = focusedIndex >= 0 ? rows[focusedIndex] : undefined\n if (row) activateRow(row)\n } else if (e.key === 'Escape') {\n onClear()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [rows, focusedIndex, activateRow, onClear, keyboardEnabled])\n\n return (\n <div className=\"max-w-[620px] mx-auto mt-3\">\n {/* Meta line. Counts matched sub-resources too — they are results in\n their own right, and a parent surfaced only via 47 matching accounts\n used to be reported as \"1 result\". Collapsed ones still count, so the\n number doesn't jump when the \"... N more\" row is expanded. */}\n <div className=\"text-[13px] text-muted-foreground mb-2 px-1\">\n {matchCount === 0\n ? `No results for \"${searchValue}\"`\n : `${matchCount} result${matchCount === 1 ? '' : 's'}`}\n </div>\n\n {/* #11: deprecated-only fallback notice */}\n {didDeprecatedFallback && (\n <div\n role=\"status\"\n className=\"text-[13px] text-muted-foreground mb-2 px-1\"\n >\n No active apps for &quot;{searchValue}&quot; — showing deprecated\n matches.\n </div>\n )}\n\n {/* Results — parents and their matched sub-resources as one flat list */}\n <div\n className=\"flex flex-col gap-1.5\"\n role=\"listbox\"\n aria-label=\"Search results\"\n >\n {rows.map((row, i) => {\n const focused = focusedIndex === i\n\n if (row.kind === 'sub') {\n const { sub, parent } = row\n const isOpen = selectedSubSlug === sub.slug\n return (\n <button\n key={row.key}\n type=\"button\"\n role=\"option\"\n data-kind=\"sub\"\n aria-selected={focused}\n // \"Which one am I looking at\" — set while this sub-resource is\n // the one singled out in its parent's detail behind the overlay.\n aria-current={isOpen ? 'true' : undefined}\n aria-label={`${sub.displayName} — sub-resource of ${parent.displayName}`}\n title={sub.displayName}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => activateRow(row)}\n className={cn(\n // Indented + left rule so the parent/child relation reads at\n // a glance without an extra text prefix.\n 'flex items-center ml-8 border-l-2 pl-3 pr-3 py-1.5 text-left',\n 'text-[13px] rounded-r-[var(--radius)] transition-colors',\n focused || isOpen\n ? 'border-primary bg-muted/40 text-foreground'\n : 'border-border text-muted-foreground hover:border-primary hover:bg-muted/30 hover:text-foreground',\n )}\n >\n <Highlight text={sub.displayName} query={searchValue} />\n </button>\n )\n }\n\n if (row.kind === 'more') {\n return (\n <button\n key={row.key}\n type=\"button\"\n role=\"option\"\n data-kind=\"more\"\n aria-selected={focused}\n aria-label={`Show ${row.hidden} more matching sub-resources of ${row.parent.displayName}`}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => activateRow(row)}\n className={cn(\n 'flex items-center ml-8 border-l-2 pl-3 pr-3 py-1.5 text-left',\n 'text-[13px] rounded-r-[var(--radius)] transition-colors',\n focused\n ? 'border-primary bg-muted/40 text-foreground'\n : 'border-border text-muted-foreground hover:border-primary hover:text-foreground',\n )}\n >\n ... {row.hidden} more\n </button>\n )\n }\n\n const app = row.app\n return (\n <button\n key={row.key}\n type=\"button\"\n role=\"option\"\n title={`View ${app.displayName}`}\n aria-selected={focused}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => activateRow(row)}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border rounded-[var(--radius)] px-3.5 py-2.5',\n 'transition-all',\n focused\n ? 'border-ring shadow-sm bg-muted/30'\n : 'border-border hover:border-ring hover:shadow-sm',\n )}\n >\n <ResourceIcon app={app} size={40} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14px] font-semibold truncate\">\n <Highlight text={app.displayName} query={searchValue} />\n {app.abbreviation && app.abbreviation !== app.displayName && (\n <span className=\"ml-1.5 text-[12px] font-normal text-muted-foreground\">\n (<Highlight text={app.abbreviation} query={searchValue} />\n )\n </span>\n )}\n </span>\n {app.description && (\n <span className=\"block text-[12.5px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n </span>\n {typeLabel(app.type) && (\n <span className=\"text-[11.5px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap shrink-0\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"size-7 shrink-0 opacity-0 group-hover:opacity-100\"\n />\n </button>\n )\n })}\n </div>\n\n {/* Keyboard hint */}\n {rows.length > 0 && (\n <p className=\"text-[11.5px] text-muted-foreground/60 text-center mt-3\">\n ↑↓ navigate · ↵ view access · esc clear\n </p>\n )}\n </div>\n )\n}\n\nexport function AppCatalogGrid({\n apps,\n allResources,\n searchValue,\n onSearchChange,\n onAppClick,\n onSubClick,\n onLaunch,\n totalCount,\n detailOpen = false,\n selectedSubSlug,\n}: AppCatalogGridProps) {\n const { getTopApps } = useAppClickHistory()\n const [topSlugs, setTopSlugs] = useState<string[]>([])\n\n useEffect(() => {\n void getTopApps(5).then(setTopSlugs)\n }, [getTopApps])\n\n const bySlug = useMemo(() => {\n const m = new Map<string, Resource>()\n for (const a of apps) m.set(a.slug, a)\n return m\n }, [apps])\n\n // Your apps: frequently-clicked (personalized). Empty on a cold new machine.\n const frequent = useMemo(\n () =>\n topSlugs\n .map((s) => bySlug.get(s))\n .filter((a): a is Resource => Boolean(a))\n .slice(0, 5),\n [topSlugs, bySlug],\n )\n\n // New this week: apps ADDED in the last 7 days — see pickNewThisWeek (#96).\n const fresh = useMemo(() => pickNewThisWeek(apps), [apps])\n\n // Browse all: everything not already promoted to \"Your apps\", alpha by name.\n const browse = useMemo(() => {\n const promoted = new Set(frequent.map((a) => a.slug))\n return [...apps]\n .filter((a) => !promoted.has(a.slug))\n .sort((a, b) => a.displayName.localeCompare(b.displayName))\n }, [apps, frequent])\n\n const browseLeft = browse.filter((_, i) => i % 2 === 0)\n const browseRight = browse.filter((_, i) => i % 2 === 1)\n\n const isSearching = searchValue.trim() !== ''\n const searchRef = useRef<HTMLInputElement>(null)\n\n // ⌘K / Ctrl+K focuses the hero search box from anywhere on the page.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault()\n searchRef.current?.focus()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [])\n\n return (\n <div className=\"mx-auto w-full max-w-[1000px] pb-24\">\n {/* hero */}\n <div className=\"text-center pt-10 pb-1.5\">\n <h1 className=\"font-serif font-semibold text-[30px] tracking-tight m-0 mb-1.5\">\n What do you need to get into?\n </h1>\n <p className=\"text-muted-foreground m-0 mb-6 text-[15px]\">\n Find any tool, see how to request access, or jump straight in.\n </p>\n <div className=\"max-w-[620px] mx-auto\">\n <div className=\"flex items-center gap-3 bg-card border-[1.5px] border-input rounded-full px-5 h-14 shadow-sm focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/15 transition-colors\">\n <Search className=\"size-5 shrink-0 text-muted-foreground\" />\n <input\n type=\"text\"\n value={searchValue}\n onChange={(e) => onSearchChange(e.target.value)}\n placeholder=\"Search apps, databases, cloud accounts, account IDs…\"\n autoComplete=\"off\"\n autoFocus\n ref={searchRef}\n aria-label=\"Search apps\"\n className=\"flex-1 bg-transparent border-0 outline-none text-[16px] text-foreground placeholder:text-muted-foreground\"\n />\n {isSearching ? (\n <button\n type=\"button\"\n aria-label=\"Clear search\"\n onClick={() => {\n onSearchChange('')\n searchRef.current?.focus()\n }}\n className=\"shrink-0 rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n <X className=\"size-4\" />\n </button>\n ) : (\n <kbd className=\"hidden sm:inline-flex items-center gap-0.5 text-[11px] text-muted-foreground/60 border border-muted-foreground/20 rounded px-1.5 py-0.5 font-mono shrink-0\">\n {typeof navigator !== 'undefined' &&\n navigator.platform.includes('Mac')\n ? '⌘K'\n : 'Ctrl+K'}\n </kbd>\n )}\n </div>\n </div>\n </div>\n\n {/* Search results: rendered in place, directly under the (unmoved) hero\n search box. The discovery spine below is what gets replaced — the\n shell, hero and search box stay put, so typing never shifts layout. */}\n {isSearching && (\n <SearchResultsList\n apps={allResources ?? apps}\n searchValue={searchValue}\n onAppClick={onAppClick}\n onSubClick={onSubClick}\n onLaunch={onLaunch}\n onClear={() => onSearchChange('')}\n keyboardEnabled={!detailOpen}\n selectedSubSlug={selectedSubSlug}\n />\n )}\n\n {/* Discovery spine: only shown when not searching */}\n {!isSearching && (\n <>\n {/* Your apps */}\n {frequent.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead title=\"Your apps\" count=\"frequent\" />\n <div className=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3.5\">\n {frequent.map((app) => (\n <button\n type=\"button\"\n key={app.slug}\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className=\"group relative bg-card border border-border rounded-[var(--radius)] px-3.5 pt-[18px] pb-4 text-center shadow-sm hover:-translate-y-0.5 hover:shadow-md transition-all\"\n >\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"absolute top-2.5 right-2.5 size-[26px] opacity-0 group-hover:opacity-100\"\n />\n <ResourceIcon\n app={app}\n size={46}\n className=\"mx-auto mb-3\"\n />\n <span className=\"block text-[13.5px] font-bold truncate\">\n {app.displayName}\n </span>\n </button>\n ))}\n </div>\n </section>\n )}\n\n {/* New this week */}\n {fresh.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead\n title=\"New this week\"\n count={`${fresh.length} added`}\n />\n <div className=\"flex flex-col gap-2\">\n {fresh.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n showNew\n />\n ))}\n </div>\n </section>\n )}\n\n {/* Browse all */}\n <section className=\"mt-9\">\n <SectionHead title=\"Browse all\" count={`${totalCount} resources`} />\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-2\">\n <div className=\"flex flex-col gap-2\">\n {browseLeft.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </div>\n <div className=\"flex flex-col gap-2\">\n {browseRight.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </div>\n </div>\n </section>\n\n <AttributionFooter />\n </>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { jsx, Fragment } from "react/jsx-runtime";
2
- import { highlightText } from "../../utils/searchApps.js";
2
+ import { highlightText } from "../../utils/highlightText.js";
3
3
  function Highlight({ text, query }) {
4
4
  if (!query.trim()) return /* @__PURE__ */ jsx(Fragment, { children: text });
5
5
  const segments = highlightText(text, query);
@@ -1 +1 @@
1
- {"version":3,"file":"Highlight.js","sources":["../../../../../../src/modules/appCatalog/ui/components/Highlight.tsx"],"sourcesContent":["import { highlightText } from '../../utils/searchApps'\n\n/**\n * Highlights all case-insensitive occurrences of `query` in `text`.\n * Renders plain text when query is empty or has no match.\n */\nexport function Highlight({ text, query }: { text: string; query: string }) {\n if (!query.trim()) return <>{text}</>\n const segments = highlightText(text, query)\n return (\n <>\n {segments.map((seg, i) =>\n seg.highlight ? (\n <mark\n key={i}\n className=\"bg-primary/15 text-primary rounded-[2px] px-[1px] font-semibold not-italic\"\n >\n {seg.text}\n </mark>\n ) : (\n seg.text\n ),\n )}\n </>\n )\n}\n"],"names":[],"mappings":";;AAMO,SAAS,UAAU,EAAE,MAAM,SAA0C;AAC1E,MAAI,CAAC,MAAM,OAAQ,wCAAU,UAAA,MAAK;AAClC,QAAM,WAAW,cAAc,MAAM,KAAK;AAC1C,yCAEK,UAAA,SAAS;AAAA,IAAI,CAAC,KAAK,MAClB,IAAI,YACF;AAAA,MAAC;AAAA,MAAA;AAAA,QAEC,WAAU;AAAA,QAET,UAAA,IAAI;AAAA,MAAA;AAAA,MAHA;AAAA,IAAA,IAMP,IAAI;AAAA,EAAA,GAGV;AAEJ;"}
1
+ {"version":3,"file":"Highlight.js","sources":["../../../../../../src/modules/appCatalog/ui/components/Highlight.tsx"],"sourcesContent":["import { highlightText } from '../../utils/highlightText'\n\n/**\n * Highlights all case-insensitive occurrences of `query` in `text`.\n * Renders plain text when query is empty or has no match.\n */\nexport function Highlight({ text, query }: { text: string; query: string }) {\n if (!query.trim()) return <>{text}</>\n const segments = highlightText(text, query)\n return (\n <>\n {segments.map((seg, i) =>\n seg.highlight ? (\n <mark\n key={i}\n className=\"bg-primary/15 text-primary rounded-[2px] px-[1px] font-semibold not-italic\"\n >\n {seg.text}\n </mark>\n ) : (\n seg.text\n ),\n )}\n </>\n )\n}\n"],"names":[],"mappings":";;AAMO,SAAS,UAAU,EAAE,MAAM,SAA0C;AAC1E,MAAI,CAAC,MAAM,OAAQ,wCAAU,UAAA,MAAK;AAClC,QAAM,WAAW,cAAc,MAAM,KAAK;AAC1C,yCAEK,UAAA,SAAS;AAAA,IAAI,CAAC,KAAK,MAClB,IAAI,YACF;AAAA,MAAC;AAAA,MAAA;AAAA,QAEC,WAAU;AAAA,QAET,UAAA,IAAI;AAAA,MAAA;AAAA,MAHA;AAAA,IAAA,IAMP,IAAI;AAAA,EAAA,GAGV;AAEJ;"}
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Compact segmented toggle for the header (Apps | Service Desks). Rendered in
2
+ * Compact segmented toggle for the header (Apps | Service Desks | MCP). Rendered in
3
3
  * the header's `middle` slot so it adds no header height. Uses router Links so
4
4
  * each segment is deep-linkable.
5
5
  *
@@ -5,6 +5,7 @@ function ViewToggle() {
5
5
  const pathname = useRouterState({ select: (s) => s.location.pathname });
6
6
  const appsActive = pathname === "/" || pathname.startsWith("/app/");
7
7
  const desksActive = pathname.startsWith("/service-desks");
8
+ const mcpActive = pathname.startsWith("/mcp");
8
9
  const segment = "inline-flex items-center rounded-md px-3 py-1 text-sm font-medium transition-colors";
9
10
  const active = "bg-background text-foreground shadow-sm";
10
11
  const inactive = "text-muted-foreground hover:text-foreground";
@@ -34,6 +35,16 @@ function ViewToggle() {
34
35
  className: cn(segment, desksActive ? active : inactive),
35
36
  children: "Service Desks"
36
37
  }
38
+ ),
39
+ /* @__PURE__ */ jsx(
40
+ Link,
41
+ {
42
+ to: "/mcp",
43
+ "aria-label": "MCP",
44
+ "aria-current": mcpActive ? "page" : void 0,
45
+ className: cn(segment, mcpActive ? active : inactive),
46
+ children: "MCP"
47
+ }
37
48
  )
38
49
  ]
39
50
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ViewToggle.js","sources":["../../../../../../src/modules/appCatalog/ui/components/ViewToggle.tsx"],"sourcesContent":["import { Link, useRouterState } from '@tanstack/react-router'\nimport { cn } from '~/lib/utils'\n\n/**\n * Compact segmented toggle for the header (Apps | Service Desks). Rendered in\n * the header's `middle` slot so it adds no header height. Uses router Links so\n * each segment is deep-linkable.\n *\n * The active segment is derived from the current pathname rather than a plain\n * exact-match on the Link: the \"Apps\" tab must stay active on the app-detail\n * routes (`/app/<slug>`) too, since the detail panel is part of the Apps view\n * (#23). A plain `activeOptions={{ exact: true }}` on `to=\"/\"` left both tabs\n * inactive on `/app/<slug>`.\n */\nexport function ViewToggle() {\n const pathname = useRouterState({ select: (s) => s.location.pathname })\n\n const appsActive = pathname === '/' || pathname.startsWith('/app/')\n const desksActive = pathname.startsWith('/service-desks')\n\n const segment =\n 'inline-flex items-center rounded-md px-3 py-1 text-sm font-medium transition-colors'\n const active = 'bg-background text-foreground shadow-sm'\n const inactive = 'text-muted-foreground hover:text-foreground'\n\n return (\n <div\n role=\"tablist\"\n aria-label=\"View\"\n className=\"inline-flex items-center gap-1 rounded-lg bg-muted p-[3px]\"\n >\n <Link\n to=\"/\"\n aria-label=\"Apps\"\n aria-current={appsActive ? 'page' : undefined}\n className={cn(segment, appsActive ? active : inactive)}\n >\n Apps\n </Link>\n <Link\n to=\"/service-desks\"\n aria-label=\"Service Desks\"\n aria-current={desksActive ? 'page' : undefined}\n className={cn(segment, desksActive ? active : inactive)}\n >\n Service Desks\n </Link>\n </div>\n )\n}\n"],"names":[],"mappings":";;;AAcO,SAAS,aAAa;AAC3B,QAAM,WAAW,eAAe,EAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,UAAU;AAEtE,QAAM,aAAa,aAAa,OAAO,SAAS,WAAW,OAAO;AAClE,QAAM,cAAc,SAAS,WAAW,gBAAgB;AAExD,QAAM,UACJ;AACF,QAAM,SAAS;AACf,QAAM,WAAW;AAEjB,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,MAAK;AAAA,MACL,cAAW;AAAA,MACX,WAAU;AAAA,MAEV,UAAA;AAAA,QAAA;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,IAAG;AAAA,YACH,cAAW;AAAA,YACX,gBAAc,aAAa,SAAS;AAAA,YACpC,WAAW,GAAG,SAAS,aAAa,SAAS,QAAQ;AAAA,YACtD,UAAA;AAAA,UAAA;AAAA,QAAA;AAAA,QAGD;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,IAAG;AAAA,YACH,cAAW;AAAA,YACX,gBAAc,cAAc,SAAS;AAAA,YACrC,WAAW,GAAG,SAAS,cAAc,SAAS,QAAQ;AAAA,YACvD,UAAA;AAAA,UAAA;AAAA,QAAA;AAAA,MAED;AAAA,IAAA;AAAA,EAAA;AAGN;"}
1
+ {"version":3,"file":"ViewToggle.js","sources":["../../../../../../src/modules/appCatalog/ui/components/ViewToggle.tsx"],"sourcesContent":["import { Link, useRouterState } from '@tanstack/react-router'\nimport { cn } from '~/lib/utils'\n\n/**\n * Compact segmented toggle for the header (Apps | Service Desks | MCP). Rendered in\n * the header's `middle` slot so it adds no header height. Uses router Links so\n * each segment is deep-linkable.\n *\n * The active segment is derived from the current pathname rather than a plain\n * exact-match on the Link: the \"Apps\" tab must stay active on the app-detail\n * routes (`/app/<slug>`) too, since the detail panel is part of the Apps view\n * (#23). A plain `activeOptions={{ exact: true }}` on `to=\"/\"` left both tabs\n * inactive on `/app/<slug>`.\n */\nexport function ViewToggle() {\n const pathname = useRouterState({ select: (s) => s.location.pathname })\n\n const appsActive = pathname === '/' || pathname.startsWith('/app/')\n const desksActive = pathname.startsWith('/service-desks')\n const mcpActive = pathname.startsWith('/mcp')\n\n const segment =\n 'inline-flex items-center rounded-md px-3 py-1 text-sm font-medium transition-colors'\n const active = 'bg-background text-foreground shadow-sm'\n const inactive = 'text-muted-foreground hover:text-foreground'\n\n return (\n <div\n role=\"tablist\"\n aria-label=\"View\"\n className=\"inline-flex items-center gap-1 rounded-lg bg-muted p-[3px]\"\n >\n <Link\n to=\"/\"\n aria-label=\"Apps\"\n aria-current={appsActive ? 'page' : undefined}\n className={cn(segment, appsActive ? active : inactive)}\n >\n Apps\n </Link>\n <Link\n to=\"/service-desks\"\n aria-label=\"Service Desks\"\n aria-current={desksActive ? 'page' : undefined}\n className={cn(segment, desksActive ? active : inactive)}\n >\n Service Desks\n </Link>\n <Link\n to=\"/mcp\"\n aria-label=\"MCP\"\n aria-current={mcpActive ? 'page' : undefined}\n className={cn(segment, mcpActive ? active : inactive)}\n >\n MCP\n </Link>\n </div>\n )\n}\n"],"names":[],"mappings":";;;AAcO,SAAS,aAAa;AAC3B,QAAM,WAAW,eAAe,EAAE,QAAQ,CAAC,MAAM,EAAE,SAAS,UAAU;AAEtE,QAAM,aAAa,aAAa,OAAO,SAAS,WAAW,OAAO;AAClE,QAAM,cAAc,SAAS,WAAW,gBAAgB;AACxD,QAAM,YAAY,SAAS,WAAW,MAAM;AAE5C,QAAM,UACJ;AACF,QAAM,SAAS;AACf,QAAM,WAAW;AAEjB,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,MAAK;AAAA,MACL,cAAW;AAAA,MACX,WAAU;AAAA,MAEV,UAAA;AAAA,QAAA;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,IAAG;AAAA,YACH,cAAW;AAAA,YACX,gBAAc,aAAa,SAAS;AAAA,YACpC,WAAW,GAAG,SAAS,aAAa,SAAS,QAAQ;AAAA,YACtD,UAAA;AAAA,UAAA;AAAA,QAAA;AAAA,QAGD;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,IAAG;AAAA,YACH,cAAW;AAAA,YACX,gBAAc,cAAc,SAAS;AAAA,YACrC,WAAW,GAAG,SAAS,cAAc,SAAS,QAAQ;AAAA,YACvD,UAAA;AAAA,UAAA;AAAA,QAAA;AAAA,QAGD;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,IAAG;AAAA,YACH,cAAW;AAAA,YACX,gBAAc,YAAY,SAAS;AAAA,YACnC,WAAW,GAAG,SAAS,YAAY,SAAS,QAAQ;AAAA,YACrD,UAAA;AAAA,UAAA;AAAA,QAAA;AAAA,MAED;AAAA,IAAA;AAAA,EAAA;AAGN;"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * MCP docs page: how to point an AI agent at this catalog.
3
+ *
4
+ * Every snippet is built from `window.location.origin`, so production, a
5
+ * preview host and localhost each show a config that actually works — nothing
6
+ * is hardcoded.
7
+ *
8
+ * The tool reference is fetched from the live registry (`tools/list`) rather
9
+ * than written out by hand, so it cannot rot when the server's tools change.
10
+ */
11
+ export declare function McpPage(): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,227 @@
1
+ import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { TriangleAlert, Check, Copy } from "lucide-react";
3
+ import { useState, useRef, useEffect, useCallback } from "react";
4
+ import { Button } from "../../../../ui/button.js";
5
+ import { Spinner } from "../../../../ui/spinner.js";
6
+ import { fetchMcpTools, MCP_ENDPOINT_PATH } from "../../utils/fetchMcpTools.js";
7
+ function McpPage() {
8
+ const origin = typeof window === "undefined" ? "" : window.location.origin;
9
+ const endpoint = `${origin}${MCP_ENDPOINT_PATH}`;
10
+ const serverName = "app-catalog";
11
+ const [tools, setTools] = useState(null);
12
+ const [error, setError] = useState(null);
13
+ const [reloadKey, setReloadKey] = useState(0);
14
+ const headingRef = useRef(null);
15
+ useEffect(() => {
16
+ var _a;
17
+ (_a = headingRef.current) == null ? void 0 : _a.focus();
18
+ }, []);
19
+ useEffect(() => {
20
+ const controller = new AbortController();
21
+ setTools(null);
22
+ setError(null);
23
+ fetchMcpTools(origin, controller.signal).then(setTools).catch((err) => {
24
+ if (controller.signal.aborted) return;
25
+ setError(
26
+ err instanceof Error ? err.message : "Could not reach the MCP endpoint."
27
+ );
28
+ });
29
+ return () => controller.abort();
30
+ }, [origin, reloadKey]);
31
+ const mcpJson = JSON.stringify(
32
+ {
33
+ mcpServers: {
34
+ [serverName]: { type: "http", url: endpoint }
35
+ }
36
+ },
37
+ null,
38
+ 2
39
+ );
40
+ const cliCommand = `claude mcp add --transport http ${serverName} ${endpoint}`;
41
+ return /* @__PURE__ */ jsx("div", { className: "flex flex-col flex-1 min-h-0 overflow-y-auto", children: /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-8 max-w-3xl pb-8", children: [
42
+ /* @__PURE__ */ jsxs("section", { className: "flex flex-col gap-2", children: [
43
+ /* @__PURE__ */ jsx(
44
+ "h1",
45
+ {
46
+ ref: headingRef,
47
+ tabIndex: -1,
48
+ className: "text-xl font-semibold outline-none",
49
+ children: "MCP server"
50
+ }
51
+ ),
52
+ /* @__PURE__ */ jsxs("p", { className: "text-sm text-muted-foreground", children: [
53
+ "This catalog exposes a",
54
+ " ",
55
+ /* @__PURE__ */ jsx(
56
+ "a",
57
+ {
58
+ href: "https://modelcontextprotocol.io",
59
+ target: "_blank",
60
+ rel: "noopener noreferrer",
61
+ className: "text-primary hover:underline",
62
+ children: "Model Context Protocol"
63
+ }
64
+ ),
65
+ " ",
66
+ "server, so an AI agent can search the catalog and read an entry the same way you can — what exists, who owns it, and how to request access. Point your agent at the endpoint below and it gains those tools; no key or account is needed."
67
+ ] }),
68
+ /* @__PURE__ */ jsxs("p", { className: "text-sm text-muted-foreground", children: [
69
+ "It speaks MCP over Streamable HTTP (JSON-RPC over",
70
+ " ",
71
+ /* @__PURE__ */ jsx("code", { className: "text-xs", children: "POST" }),
72
+ "), and it is read-only."
73
+ ] })
74
+ ] }),
75
+ /* @__PURE__ */ jsx(
76
+ Snippet,
77
+ {
78
+ title: "Endpoint",
79
+ description: "The URL an MCP client connects to.",
80
+ value: endpoint
81
+ }
82
+ ),
83
+ /* @__PURE__ */ jsx(
84
+ Snippet,
85
+ {
86
+ title: "Add it with the CLI",
87
+ description: "One command, if your agent ships a CLI that manages MCP servers.",
88
+ value: cliCommand
89
+ }
90
+ ),
91
+ /* @__PURE__ */ jsx(
92
+ Snippet,
93
+ {
94
+ title: "Or add it to .mcp.json",
95
+ description: "Commit this in a project so everyone working on it gets the same tools.",
96
+ value: mcpJson,
97
+ language: "json"
98
+ }
99
+ ),
100
+ /* @__PURE__ */ jsxs(
101
+ "section",
102
+ {
103
+ className: "flex flex-col gap-3",
104
+ "aria-busy": tools === null && error === null,
105
+ children: [
106
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1", children: [
107
+ /* @__PURE__ */ jsx("h2", { className: "text-base font-semibold", children: "Tools" }),
108
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "Read live from the server, so this list is always what your agent will actually see." })
109
+ ] }),
110
+ error !== null ? /* @__PURE__ */ jsxs(
111
+ "div",
112
+ {
113
+ role: "alert",
114
+ className: "flex flex-col items-start gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-4",
115
+ children: [
116
+ /* @__PURE__ */ jsxs("p", { className: "flex items-start gap-2 text-sm", children: [
117
+ /* @__PURE__ */ jsx(TriangleAlert, { className: "size-4 shrink-0 mt-0.5 text-destructive" }),
118
+ /* @__PURE__ */ jsxs("span", { children: [
119
+ "Could not read the tool list. ",
120
+ error,
121
+ " The setup instructions above are still correct."
122
+ ] })
123
+ ] }),
124
+ /* @__PURE__ */ jsx(
125
+ Button,
126
+ {
127
+ variant: "outline",
128
+ size: "sm",
129
+ onClick: () => setReloadKey((k) => k + 1),
130
+ children: "Try again"
131
+ }
132
+ )
133
+ ]
134
+ }
135
+ ) : tools === null ? /* @__PURE__ */ jsxs("p", { className: "flex items-center gap-2 text-sm text-muted-foreground", children: [
136
+ /* @__PURE__ */ jsx(Spinner, { className: "size-4" }),
137
+ "Reading the tool list…"
138
+ ] }) : tools.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "The server reported no tools." }) : /* @__PURE__ */ jsx("ul", { className: "flex flex-col gap-4 list-none p-0 m-0", children: tools.map((tool) => /* @__PURE__ */ jsxs(
139
+ "li",
140
+ {
141
+ className: "rounded-md border p-4 flex flex-col gap-2",
142
+ children: [
143
+ /* @__PURE__ */ jsx("code", { className: "text-sm font-semibold", children: tool.name }),
144
+ tool.description && /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: tool.description }),
145
+ tool.parameters.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "No parameters." }) : /* @__PURE__ */ jsx("dl", { className: "flex flex-col gap-2 m-0", children: tool.parameters.map((param) => /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-0.5", children: [
146
+ /* @__PURE__ */ jsxs("dt", { className: "text-xs", children: [
147
+ /* @__PURE__ */ jsx("code", { className: "font-medium", children: param.name }),
148
+ param.type && /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
149
+ " ",
150
+ param.type
151
+ ] }),
152
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
153
+ " ",
154
+ "— ",
155
+ param.required ? "required" : "optional"
156
+ ] })
157
+ ] }),
158
+ param.description && /* @__PURE__ */ jsx("dd", { className: "text-xs text-muted-foreground m-0", children: param.description }),
159
+ param.enumValues && /* @__PURE__ */ jsxs("dd", { className: "text-xs text-muted-foreground m-0", children: [
160
+ "One of: ",
161
+ param.enumValues.join(", ")
162
+ ] })
163
+ ] }, param.name)) })
164
+ ]
165
+ },
166
+ tool.name
167
+ )) })
168
+ ]
169
+ }
170
+ )
171
+ ] }) });
172
+ }
173
+ function Snippet({
174
+ title,
175
+ description,
176
+ value,
177
+ language
178
+ }) {
179
+ return /* @__PURE__ */ jsxs("section", { className: "flex flex-col gap-2", children: [
180
+ /* @__PURE__ */ jsx("h2", { className: "text-base font-semibold", children: title }),
181
+ /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: description }),
182
+ /* @__PURE__ */ jsxs("div", { className: "relative", children: [
183
+ /* @__PURE__ */ jsx(
184
+ "pre",
185
+ {
186
+ className: "overflow-x-auto rounded-md border bg-muted/40 p-3 pr-12 text-xs",
187
+ "data-language": language,
188
+ children: /* @__PURE__ */ jsx("code", { children: value })
189
+ }
190
+ ),
191
+ /* @__PURE__ */ jsx(CopyButton, { value, label: title })
192
+ ] })
193
+ ] });
194
+ }
195
+ function CopyButton({ value, label }) {
196
+ const [copied, setCopied] = useState(false);
197
+ const timeoutRef = useRef(null);
198
+ useEffect(
199
+ () => () => {
200
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
201
+ },
202
+ []
203
+ );
204
+ const copy = useCallback(() => {
205
+ void navigator.clipboard.writeText(value);
206
+ setCopied(true);
207
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
208
+ timeoutRef.current = setTimeout(() => setCopied(false), 2e3);
209
+ }, [value]);
210
+ return /* @__PURE__ */ jsx(
211
+ Button,
212
+ {
213
+ type: "button",
214
+ variant: "ghost",
215
+ size: "icon",
216
+ onClick: copy,
217
+ "aria-label": copied ? `${label} copied` : `Copy ${label}`,
218
+ title: copied ? "Copied" : "Copy",
219
+ className: "absolute right-1.5 top-1.5",
220
+ children: copied ? /* @__PURE__ */ jsx(Check, { className: "size-4 text-primary" }) : /* @__PURE__ */ jsx(Copy, { className: "size-4" })
221
+ }
222
+ );
223
+ }
224
+ export {
225
+ McpPage
226
+ };
227
+ //# sourceMappingURL=McpPage.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"McpPage.js","sources":["../../../../../../src/modules/appCatalog/ui/pages/McpPage.tsx"],"sourcesContent":["import { Check, Copy, TriangleAlert } from 'lucide-react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { Button } from '~/ui/button'\nimport { Spinner } from '~/ui/spinner'\nimport type { McpTool } from '../../utils/fetchMcpTools'\nimport { MCP_ENDPOINT_PATH, fetchMcpTools } from '../../utils/fetchMcpTools'\n\n/**\n * MCP docs page: how to point an AI agent at this catalog.\n *\n * Every snippet is built from `window.location.origin`, so production, a\n * preview host and localhost each show a config that actually works — nothing\n * is hardcoded.\n *\n * The tool reference is fetched from the live registry (`tools/list`) rather\n * than written out by hand, so it cannot rot when the server's tools change.\n */\nexport function McpPage() {\n const origin = typeof window === 'undefined' ? '' : window.location.origin\n const endpoint = `${origin}${MCP_ENDPOINT_PATH}`\n const serverName = 'app-catalog'\n\n const [tools, setTools] = useState<McpTool[] | null>(null)\n const [error, setError] = useState<string | null>(null)\n const [reloadKey, setReloadKey] = useState(0)\n const headingRef = useRef<HTMLHeadingElement>(null)\n\n // New view: land keyboard and screen-reader users in the content rather than\n // leaving focus on whatever nav element was clicked.\n useEffect(() => {\n headingRef.current?.focus()\n }, [])\n\n useEffect(() => {\n const controller = new AbortController()\n setTools(null)\n setError(null)\n fetchMcpTools(origin, controller.signal)\n .then(setTools)\n .catch((err: unknown) => {\n if (controller.signal.aborted) return\n setError(\n err instanceof Error\n ? err.message\n : 'Could not reach the MCP endpoint.',\n )\n })\n return () => controller.abort()\n }, [origin, reloadKey])\n\n const mcpJson = JSON.stringify(\n {\n mcpServers: {\n [serverName]: { type: 'http', url: endpoint },\n },\n },\n null,\n 2,\n )\n const cliCommand = `claude mcp add --transport http ${serverName} ${endpoint}`\n\n return (\n <div className=\"flex flex-col flex-1 min-h-0 overflow-y-auto\">\n <div className=\"flex flex-col gap-8 max-w-3xl pb-8\">\n <section className=\"flex flex-col gap-2\">\n <h1\n ref={headingRef}\n tabIndex={-1}\n className=\"text-xl font-semibold outline-none\"\n >\n MCP server\n </h1>\n <p className=\"text-sm text-muted-foreground\">\n This catalog exposes a{' '}\n <a\n href=\"https://modelcontextprotocol.io\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"text-primary hover:underline\"\n >\n Model Context Protocol\n </a>{' '}\n server, so an AI agent can search the catalog and read an entry the\n same way you can — what exists, who owns it, and how to request\n access. Point your agent at the endpoint below and it gains those\n tools; no key or account is needed.\n </p>\n <p className=\"text-sm text-muted-foreground\">\n It speaks MCP over Streamable HTTP (JSON-RPC over{' '}\n <code className=\"text-xs\">POST</code>), and it is read-only.\n </p>\n </section>\n\n <Snippet\n title=\"Endpoint\"\n description=\"The URL an MCP client connects to.\"\n value={endpoint}\n />\n\n <Snippet\n title=\"Add it with the CLI\"\n description=\"One command, if your agent ships a CLI that manages MCP servers.\"\n value={cliCommand}\n />\n\n <Snippet\n title=\"Or add it to .mcp.json\"\n description=\"Commit this in a project so everyone working on it gets the same tools.\"\n value={mcpJson}\n language=\"json\"\n />\n\n <section\n className=\"flex flex-col gap-3\"\n aria-busy={tools === null && error === null}\n >\n <div className=\"flex flex-col gap-1\">\n <h2 className=\"text-base font-semibold\">Tools</h2>\n <p className=\"text-xs text-muted-foreground\">\n Read live from the server, so this list is always what your agent\n will actually see.\n </p>\n </div>\n\n {error !== null ? (\n <div\n role=\"alert\"\n className=\"flex flex-col items-start gap-3 rounded-md border border-destructive/40 bg-destructive/5 p-4\"\n >\n <p className=\"flex items-start gap-2 text-sm\">\n <TriangleAlert className=\"size-4 shrink-0 mt-0.5 text-destructive\" />\n <span>\n Could not read the tool list. {error} The setup instructions\n above are still correct.\n </span>\n </p>\n <Button\n variant=\"outline\"\n size=\"sm\"\n onClick={() => setReloadKey((k) => k + 1)}\n >\n Try again\n </Button>\n </div>\n ) : tools === null ? (\n <p className=\"flex items-center gap-2 text-sm text-muted-foreground\">\n <Spinner className=\"size-4\" />\n Reading the tool list…\n </p>\n ) : tools.length === 0 ? (\n <p className=\"text-sm text-muted-foreground\">\n The server reported no tools.\n </p>\n ) : (\n <ul className=\"flex flex-col gap-4 list-none p-0 m-0\">\n {tools.map((tool) => (\n <li\n key={tool.name}\n className=\"rounded-md border p-4 flex flex-col gap-2\"\n >\n <code className=\"text-sm font-semibold\">{tool.name}</code>\n {tool.description && (\n <p className=\"text-sm text-muted-foreground\">\n {tool.description}\n </p>\n )}\n {tool.parameters.length === 0 ? (\n <p className=\"text-xs text-muted-foreground\">\n No parameters.\n </p>\n ) : (\n <dl className=\"flex flex-col gap-2 m-0\">\n {tool.parameters.map((param) => (\n <div key={param.name} className=\"flex flex-col gap-0.5\">\n <dt className=\"text-xs\">\n <code className=\"font-medium\">{param.name}</code>\n {param.type && (\n <span className=\"text-muted-foreground\">\n {' '}\n {param.type}\n </span>\n )}\n <span className=\"text-muted-foreground\">\n {' '}\n — {param.required ? 'required' : 'optional'}\n </span>\n </dt>\n {param.description && (\n <dd className=\"text-xs text-muted-foreground m-0\">\n {param.description}\n </dd>\n )}\n {param.enumValues && (\n <dd className=\"text-xs text-muted-foreground m-0\">\n One of: {param.enumValues.join(', ')}\n </dd>\n )}\n </div>\n ))}\n </dl>\n )}\n </li>\n ))}\n </ul>\n )}\n </section>\n </div>\n </div>\n )\n}\n\nfunction Snippet({\n title,\n description,\n value,\n language,\n}: {\n title: string\n description: string\n value: string\n language?: string\n}) {\n return (\n <section className=\"flex flex-col gap-2\">\n <h2 className=\"text-base font-semibold\">{title}</h2>\n <p className=\"text-xs text-muted-foreground\">{description}</p>\n <div className=\"relative\">\n <pre\n className=\"overflow-x-auto rounded-md border bg-muted/40 p-3 pr-12 text-xs\"\n data-language={language}\n >\n <code>{value}</code>\n </pre>\n <CopyButton value={value} label={title} />\n </div>\n </section>\n )\n}\n\nfunction CopyButton({ value, label }: { value: string; label: string }) {\n const [copied, setCopied] = useState(false)\n const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)\n\n useEffect(\n () => () => {\n if (timeoutRef.current) clearTimeout(timeoutRef.current)\n },\n [],\n )\n\n const copy = useCallback(() => {\n void navigator.clipboard.writeText(value)\n setCopied(true)\n if (timeoutRef.current) clearTimeout(timeoutRef.current)\n timeoutRef.current = setTimeout(() => setCopied(false), 2000)\n }, [value])\n\n return (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon\"\n onClick={copy}\n aria-label={copied ? `${label} copied` : `Copy ${label}`}\n title={copied ? 'Copied' : 'Copy'}\n className=\"absolute right-1.5 top-1.5\"\n >\n {copied ? (\n <Check className=\"size-4 text-primary\" />\n ) : (\n <Copy className=\"size-4\" />\n )}\n </Button>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Minimal MCP client for the docs page: one `tools/list` call over Streamable
3
+ * HTTP. Deliberately raw `fetch` rather than the MCP SDK — the page needs one
4
+ * unauthenticated read and nothing else.
5
+ *
6
+ * The endpoint is stateless (a fresh server per request), so `tools/list`
7
+ * stands alone with no `initialize` handshake. It may answer with either JSON
8
+ * or a single SSE event, so both are parsed.
9
+ */
10
+ export interface McpToolParameter {
11
+ name: string;
12
+ type?: string;
13
+ required: boolean;
14
+ description?: string;
15
+ enumValues?: string[];
16
+ }
17
+ export interface McpTool {
18
+ name: string;
19
+ title?: string;
20
+ description?: string;
21
+ parameters: McpToolParameter[];
22
+ }
23
+ /** Path the MCP server is mounted at, relative to the site origin. */
24
+ export declare const MCP_ENDPOINT_PATH = "/api/mcp";
25
+ /**
26
+ * Fetch the live tool registry so this page cannot go stale when the tools
27
+ * change.
28
+ *
29
+ * @param origin - Site origin, e.g. `window.location.origin`
30
+ * @param signal - Optional abort signal
31
+ */
32
+ export declare function fetchMcpTools(origin: string, signal?: AbortSignal): Promise<McpTool[]>;