Synthetic installed cards ran their own letter-mark fallback while the sidebar resolved real icons for the same integrations — two resolvers for one integration. They now run the identical cascade: preset icon by exact identity, else the logo proxy from the integration's own URL. The page also stops scrolling as a whole: the title, search, and scratch row pin, and the results grid scrolls in its own container — an endless list makes the page scrollbar meaningless and drags the search box off screen.
Unified · Split
| @@ -19,7 +19,12 @@ import { slugifyNamespace } from "../plugins/namespace"; · 18 unmodified lines | ||
| 19 | 19 | import { trackEvent } from "../api/analytics"; |
| 20 | 20 | import { Button } from "../components/button"; |
| 21 | 21 | import { Input } from "../components/input"; |
| 22 | −import { PageContainer, PageHeader } from "../components/page"; | |
| 22 | +import { PageHeader } from "../components/page"; | |
| 23 | +import { | |
| 24 | + integrationFaviconUrl, | |
| 25 | + integrationInferredUrl, | |
| 26 | + integrationPresetIconUrl, | |
| 27 | +} from "../components/integration-favicon"; | |
| 23 | 28 | import { Skeleton } from "../components/skeleton"; |
| 24 | 29 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 25 | 30 | import { |
| @@ -804,8 +809,28 @@ export function IntegrationBrowsePage() { · 778 unmodified lines | ||
| 804 | 809 | row.name.toLowerCase().includes(text) || |
| 805 | 810 | String(row.slug).includes(text)), |
| 806 | 811 | ) |
| 807 | − .map( | |
| 808 | − (row): Row => ({ | |
| 812 | + .map((row): Row => { | |
| 813 | + // The SAME icon cascade the sidebar runs — preset icon by exact | |
| 814 | + // identity, else the logo proxy from the integration's own URL. Two | |
| 815 | + // resolvers for one integration is how the sidebar and the picker | |
| 816 | + // ended up disagreeing about what DeepWiki looks like. | |
| 817 | + const iconUrl = | |
| 818 | + integrationPresetIconUrl( | |
| 819 | + { | |
| 820 | + id: String(row.slug), | |
| 821 | + kind: row.kind, | |
| 822 | + name: row.name, | |
| 823 | + ...(row.displayUrl ? { url: row.displayUrl } : {}), | |
| 824 | + }, | |
| 825 | + integrationPlugins, | |
| 826 | + ) ?? | |
| 827 | + integrationFaviconUrl( | |
| 828 | + row.displayUrl ?? | |
| 829 | + integrationInferredUrl({ id: String(row.slug), name: row.name }) ?? | |
| 830 | + undefined, | |
| 831 | + 10, | |
| 832 | + ); | |
| 833 | + return { | |
| 809 | 834 | key: `installed-${String(row.slug)}`, |
| 810 | 835 | testId: `installed-${String(row.slug)}`, |
| 811 | 836 | title: row.name, |
| @@ -813,134 +838,155 @@ export function IntegrationBrowsePage() { · 1 unmodified line | ||
| 813 | 838 | ...(row.description && row.description !== row.name |
| 814 | 839 | ? { description: tidyDescription(row.description) } |
| 815 | 840 | : {}), |
| 841 | + ...(iconUrl ? { iconUrl } : {}), | |
| 816 | 842 | onSelect: () => {}, |
| 817 | 843 | added: true, |
| 818 | 844 | viewSlug: String(row.slug), |
| 819 | 845 | busy: false, |
| 820 | − }), | |
| 821 | − ); | |
| 846 | + }; | |
| 847 | + }); | |
| 822 | 848 | return [...installedRows, ...merged.filter(floats), ...merged.filter((row) => !floats(row))]; |
| 823 | − }, [presetRows, catalogRows, catalog.loading, catalog.failed, installed, text]); | |
| 849 | + }, [ | |
| 850 | + presetRows, | |
| 851 | + catalogRows, | |
| 852 | + catalog.loading, | |
| 853 | + catalog.failed, | |
| 854 | + installed, | |
| 855 | + text, | |
| 856 | + integrationPlugins, | |
| 857 | + ]); | |
| 824 | 858 | |
| 825 | 859 | // Presets are local, so a list that already has them is not empty — show |
| 826 | 860 | // skeletons only when there is genuinely nothing on screen yet. |
| 827 | 861 | const loading = catalog.loading && results.length === 0; |
| 828 | 862 | |
| 829 | 863 | return ( |
| 830 | − <PageContainer> | |
| 864 | + // PageContainer scrolls the whole page; this page pins the header and | |
| 865 | + // search, and ONLY the results grid scrolls — an endless list makes the | |
| 866 | + // page scrollbar meaningless and drags the search box off screen. | |
| 867 | + <div className="flex min-h-0 flex-1 flex-col" data-slot="page-container"> | |
| 831 | 868 | {integrationPlugins.map((plugin) => ( |
| 832 | 869 | <QuickAddBridge key={plugin.key} plugin={plugin} register={registerQuickAdd} /> |
| 833 | 870 | ))} |
| 834 | − <PageHeader | |
| 835 | − title="Add an integration" | |
| 836 | − description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint." | |
| 837 | − /> | |
| 838 | − | |
| 839 | − <div className="mb-4 flex gap-2"> | |
| 840 | − <div className="relative min-w-0 flex-1"> | |
| 841 | − <SearchIcon | |
| 842 | − className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" | |
| 843 | − aria-hidden | |
| 844 | − /> | |
| 845 | − <Input | |
| 846 | − type="text" | |
| 847 | − value={query} | |
| 848 | − onChange={(event) => { | |
| 849 | − setQuery((event.target as HTMLInputElement).value); | |
| 850 | − setError(null); | |
| 851 | − }} | |
| 852 | − onKeyDown={(event) => { | |
| 853 | − if (event.key === "Enter" && isUrl) void handleDetect(); | |
| 854 | − }} | |
| 855 | − placeholder="Search integrations, or paste a URL…" | |
| 856 | − aria-label="Search integrations, or paste a URL" | |
| 857 | − disabled={detecting} | |
| 858 | − // oxlint-disable-next-line jsx_a11y/no-autofocus -- deliberate: searching is the page's only purpose, and it is reached by an explicit "Add integration" action | |
| 859 | − autoFocus | |
| 860 | − className="h-11 pl-9 text-sm" | |
| 861 | − /> | |
| 871 | + <div className="mx-auto w-full max-w-4xl shrink-0 px-6 pt-10 lg:px-8 lg:pt-14"> | |
| 872 | + <PageHeader | |
| 873 | + title="Add an integration" | |
| 874 | + description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint." | |
| 875 | + /> | |
| 876 | + | |
| 877 | + <div className="mb-4 flex gap-2"> | |
| 878 | + <div className="relative min-w-0 flex-1"> | |
| 879 | + <SearchIcon | |
| 880 | + className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" | |
| 881 | + aria-hidden | |
| 882 | + /> | |
| 883 | + <Input | |
| 884 | + type="text" | |
| 885 | + value={query} | |
| 886 | + onChange={(event) => { | |
| 887 | + setQuery((event.target as HTMLInputElement).value); | |
| 888 | + setError(null); | |
| 889 | + }} | |
| 890 | + onKeyDown={(event) => { | |
| 891 | + if (event.key === "Enter" && isUrl) void handleDetect(); | |
| 892 | + }} | |
| 893 | + placeholder="Search integrations, or paste a URL…" | |
| 894 | + aria-label="Search integrations, or paste a URL" | |
| 895 | + disabled={detecting} | |
| 896 | + // oxlint-disable-next-line jsx_a11y/no-autofocus -- deliberate: searching is the page's only purpose, and it is reached by an explicit "Add integration" action | |
| 897 | + autoFocus | |
| 898 | + className="h-11 pl-9 text-sm" | |
| 899 | + /> | |
| 900 | + </div> | |
| 901 | + {isUrl ? ( | |
| 902 | + <Button | |
| 903 | + className="h-11 shrink-0" | |
| 904 | + onClick={() => void handleDetect()} | |
| 905 | + disabled={detecting || query.trim().length === 0} | |
| 906 | + loading={detecting} | |
| 907 | + > | |
| 908 | + Add this URL | |
| 909 | + </Button> | |
| 910 | + ) : null} | |
| 862 | 911 | </div> |
| 863 | − {isUrl ? ( | |
| 864 | − <Button | |
| 865 | − className="h-11 shrink-0" | |
| 866 | − onClick={() => void handleDetect()} | |
| 867 | − disabled={detecting || query.trim().length === 0} | |
| 868 | − loading={detecting} | |
| 869 | − > | |
| 870 | − Add this URL | |
| 871 | − </Button> | |
| 872 | − ) : null} | |
| 873 | − </div> | |
| 874 | 912 | |
| 875 | − {/* Above the results, not below: an endless list has no reachable | |
| 913 | + {/* Above the results, not below: an endless list has no reachable | |
| 876 | 914 | bottom, and this is the escape hatch for exactly the person the |
| 877 | 915 | list is failing. One quiet line; the label carries the action, so |
| 878 | 916 | the chips stay bare format names. */} |
| 879 | − <div className="mb-8 flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 880 | − <span className="shrink-0 text-xs text-muted-foreground">Start from scratch:</span> | |
| 881 | − <div className="flex shrink-0 items-center gap-1.5"> | |
| 882 | − {integrationPlugins.map( | |
| 883 | − (plugin: IntegrationPlugin): ReactNode => ( | |
| 884 | − // oxlint-disable-next-line react/forbid-elements -- a chip, not a Button variant | |
| 885 | − <button | |
| 886 | − key={plugin.key} | |
| 887 | − type="button" | |
| 888 | − aria-label={`New ${plugin.label} integration from scratch`} | |
| 889 | − onClick={() => { | |
| 890 | − trackEvent("integration_add_started", { plugin_key: plugin.key, via: "manual" }); | |
| 891 | − void navigate({ | |
| 892 | − to: "/{-$orgSlug}/integrations/add/$pluginKey", | |
| 893 | − params: { pluginKey: plugin.key }, | |
| 894 | − }); | |
| 895 | − }} | |
| 896 | − className="inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" | |
| 897 | − > | |
| 898 | − <PlusIcon className="size-3" aria-hidden /> | |
| 899 | − {plugin.label} | |
| 900 | − </button> | |
| 901 | − ), | |
| 902 | − )} | |
| 917 | + <div className="mb-8 flex flex-wrap items-center gap-x-3 gap-y-2"> | |
| 918 | + <span className="shrink-0 text-xs text-muted-foreground">Start from scratch:</span> | |
| 919 | + <div className="flex shrink-0 items-center gap-1.5"> | |
| 920 | + {integrationPlugins.map( | |
| 921 | + (plugin: IntegrationPlugin): ReactNode => ( | |
| 922 | + // oxlint-disable-next-line react/forbid-elements -- a chip, not a Button variant | |
| 923 | + <button | |
| 924 | + key={plugin.key} | |
| 925 | + type="button" | |
| 926 | + aria-label={`New ${plugin.label} integration from scratch`} | |
| 927 | + onClick={() => { | |
| 928 | + trackEvent("integration_add_started", { | |
| 929 | + plugin_key: plugin.key, | |
| 930 | + via: "manual", | |
| 931 | + }); | |
| 932 | + void navigate({ | |
| 933 | + to: "/{-$orgSlug}/integrations/add/$pluginKey", | |
| 934 | + params: { pluginKey: plugin.key }, | |
| 935 | + }); | |
| 936 | + }} | |
| 937 | + className="inline-flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" | |
| 938 | + > | |
| 939 | + <PlusIcon className="size-3" aria-hidden /> | |
| 940 | + {plugin.label} | |
| 941 | + </button> | |
| 942 | + ), | |
| 943 | + )} | |
| 944 | + </div> | |
| 945 | + <span className="ml-auto shrink-0 text-xs text-muted-foreground"> | |
| 946 | + Can't find it? Paste its URL above. | |
| 947 | + </span> | |
| 903 | 948 | </div> |
| 904 | − <span className="ml-auto shrink-0 text-xs text-muted-foreground"> | |
| 905 | − Can't find it? Paste its URL above. | |
| 906 | − </span> | |
| 907 | − </div> | |
| 908 | 949 | |
| 909 | − {error ? ( | |
| 910 | − <p role="alert" className="mb-4 text-xs text-destructive"> | |
| 911 | − {error} | |
| 912 | − </p> | |
| 913 | − ) : null} | |
| 950 | + {error ? ( | |
| 951 | + <p role="alert" className="mb-4 text-xs text-destructive"> | |
| 952 | + {error} | |
| 953 | + </p> | |
| 954 | + ) : null} | |
| 955 | + </div> | |
| 914 | 956 | |
| 915 | − {loading ? ( | |
| 916 | − <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 917 | − {Array.from({ length: 9 }).map((_, index) => ( | |
| 918 | − <CardSkeleton key={index} index={index} /> | |
| 919 | − ))} | |
| 920 | − </div> | |
| 921 | − ) : results.length === 0 ? ( | |
| 922 | − <p className="px-3 py-10 text-center text-sm text-muted-foreground"> | |
| 923 | − {catalog.failed | |
| 924 | − ? "Couldn't load the full list right now. You can still paste the URL of an MCP server, OpenAPI spec, or GraphQL endpoint to add it directly." | |
| 925 | − : "Nothing matches that. Paste the URL of an MCP server, OpenAPI spec, or GraphQL endpoint to add it directly."} | |
| 926 | − </p> | |
| 927 | − ) : ( | |
| 928 | − <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 929 | − {results.map((row) => ( | |
| 930 | − <ResultCard key={row.key} row={row} /> | |
| 931 | − ))} | |
| 932 | − {catalog.loadingMore | |
| 933 | − ? Array.from({ length: 3 }, (_, index) => ( | |
| 934 | − <CardSkeleton key={`more-${index}`} index={index} /> | |
| 935 | − )) | |
| 936 | − : null} | |
| 937 | − {catalog.hasMore ? ( | |
| 938 | − <div className="col-span-full"> | |
| 939 | − <LoadMoreSentinel onVisible={catalog.loadMore} /> | |
| 957 | + <div className="min-h-0 flex-1 overflow-y-auto"> | |
| 958 | + <div className="mx-auto w-full max-w-4xl px-6 pb-10 lg:px-8"> | |
| 959 | + {loading ? ( | |
| 960 | + <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 961 | + {Array.from({ length: 9 }).map((_, index) => ( | |
| 962 | + <CardSkeleton key={index} index={index} /> | |
| 963 | + ))} | |
| 940 | 964 | </div> |
| 941 | − ) : null} | |
| 965 | + ) : results.length === 0 ? ( | |
| 966 | + <p className="px-3 py-10 text-center text-sm text-muted-foreground"> | |
| 967 | + {catalog.failed | |
| 968 | + ? "Couldn't load the full list right now. You can still paste the URL of an MCP server, OpenAPI spec, or GraphQL endpoint to add it directly." | |
| 969 | + : "Nothing matches that. Paste the URL of an MCP server, OpenAPI spec, or GraphQL endpoint to add it directly."} | |
| 970 | + </p> | |
| 971 | + ) : ( | |
| 972 | + <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"> | |
| 973 | + {results.map((row) => ( | |
| 974 | + <ResultCard key={row.key} row={row} /> | |
| 975 | + ))} | |
| 976 | + {catalog.loadingMore | |
| 977 | + ? Array.from({ length: 3 }, (_, index) => ( | |
| 978 | + <CardSkeleton key={`more-${index}`} index={index} /> | |
| 979 | + )) | |
| 980 | + : null} | |
| 981 | + {catalog.hasMore ? ( | |
| 982 | + <div className="col-span-full"> | |
| 983 | + <LoadMoreSentinel onVisible={catalog.loadMore} /> | |
| 984 | + </div> | |
| 985 | + ) : null} | |
| 986 | + </div> | |
| 987 | + )} | |
| 942 | 988 | </div> |
| 943 | − )} | |
| 944 | − </PageContainer> | |
| 989 | + </div> | |
| 990 | + </div> | |
| 945 | 991 | ); |
| 946 | 992 | } |