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 | import { trackEvent } from "../api/analytics"; | 19 | import { trackEvent } from "../api/analytics"; |
| 20 | import { Button } from "../components/button"; | 20 | import { Button } from "../components/button"; |
| 21 | import { Input } from "../components/input"; | 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 | import { Skeleton } from "../components/skeleton"; | 28 | import { Skeleton } from "../components/skeleton"; |
| 24 | import { useExecutorDocumentTitle } from "../lib/document-title"; | 29 | import { useExecutorDocumentTitle } from "../lib/document-title"; |
| 25 | import { | 30 | import { |
| @@ -804,8 +809,28 @@ export function IntegrationBrowsePage() { · 778 unmodified lines | |||
| 804 | row.name.toLowerCase().includes(text) || | 809 | row.name.toLowerCase().includes(text) || |
| 805 | String(row.slug).includes(text)), | 810 | String(row.slug).includes(text)), |
| 806 | ) | 811 | ) |
| 807 | .map( | 812 | .map((row): Row => { |
| 808 | (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 | key: `installed-${String(row.slug)}`, | 834 | key: `installed-${String(row.slug)}`, |
| 810 | testId: `installed-${String(row.slug)}`, | 835 | testId: `installed-${String(row.slug)}`, |
| 811 | title: row.name, | 836 | title: row.name, |
| @@ -813,134 +838,155 @@ export function IntegrationBrowsePage() { · 1 unmodified line | |||
| 813 | ...(row.description && row.description !== row.name | 838 | ...(row.description && row.description !== row.name |
| 814 | ? { description: tidyDescription(row.description) } | 839 | ? { description: tidyDescription(row.description) } |
| 815 | : {}), | 840 | : {}), |
| 841 | ...(iconUrl ? { iconUrl } : {}), | ||
| 816 | onSelect: () => {}, | 842 | onSelect: () => {}, |
| 817 | added: true, | 843 | added: true, |
| 818 | viewSlug: String(row.slug), | 844 | viewSlug: String(row.slug), |
| 819 | busy: false, | 845 | busy: false, |
| 820 | }), | 846 | }; |
| 821 | ); | 847 | }); |
| 822 | return [...installedRows, ...merged.filter(floats), ...merged.filter((row) => !floats(row))]; | 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 | // Presets are local, so a list that already has them is not empty — show | 859 | // Presets are local, so a list that already has them is not empty — show |
| 826 | // skeletons only when there is genuinely nothing on screen yet. | 860 | // skeletons only when there is genuinely nothing on screen yet. |
| 827 | const loading = catalog.loading && results.length === 0; | 861 | const loading = catalog.loading && results.length === 0; |
| 828 | 862 | ||
| 829 | return ( | 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 | {integrationPlugins.map((plugin) => ( | 868 | {integrationPlugins.map((plugin) => ( |
| 832 | <QuickAddBridge key={plugin.key} plugin={plugin} register={registerQuickAdd} /> | 869 | <QuickAddBridge key={plugin.key} plugin={plugin} register={registerQuickAdd} /> |
| 833 | ))} | 870 | ))} |
| 834 | <PageHeader | 871 | <div className="mx-auto w-full max-w-4xl shrink-0 px-6 pt-10 lg:px-8 lg:pt-14"> |
| 835 | title="Add an integration" | 872 | <PageHeader |
| 836 | description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint." | 873 | title="Add an integration" |
| 837 | /> | 874 | description="Search for a service, or point executor at any MCP server, OpenAPI spec, or GraphQL endpoint." |
| 838 | 875 | /> | |
| 839 | <div className="mb-4 flex gap-2"> | 876 | |
| 840 | <div className="relative min-w-0 flex-1"> | 877 | <div className="mb-4 flex gap-2"> |
| 841 | <SearchIcon | 878 | <div className="relative min-w-0 flex-1"> |
| 842 | className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" | 879 | <SearchIcon |
| 843 | aria-hidden | 880 | className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" |
| 844 | /> | 881 | aria-hidden |
| 845 | <Input | 882 | /> |
| 846 | type="text" | 883 | <Input |
| 847 | value={query} | 884 | type="text" |
| 848 | onChange={(event) => { | 885 | value={query} |
| 849 | setQuery((event.target as HTMLInputElement).value); | 886 | onChange={(event) => { |
| 850 | setError(null); | 887 | setQuery((event.target as HTMLInputElement).value); |
| 851 | }} | 888 | setError(null); |
| 852 | onKeyDown={(event) => { | 889 | }} |
| 853 | if (event.key === "Enter" && isUrl) void handleDetect(); | 890 | onKeyDown={(event) => { |
| 854 | }} | 891 | if (event.key === "Enter" && isUrl) void handleDetect(); |
| 855 | placeholder="Search integrations, or paste a URL…" | 892 | }} |
| 856 | aria-label="Search integrations, or paste a URL" | 893 | placeholder="Search integrations, or paste a URL…" |
| 857 | disabled={detecting} | 894 | aria-label="Search integrations, or paste a URL" |
| 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 | 895 | disabled={detecting} |
| 859 | autoFocus | 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 |
| 860 | className="h-11 pl-9 text-sm" | 897 | autoFocus |
| 861 | /> | 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 | </div> | 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 | bottom, and this is the escape hatch for exactly the person the | 914 | bottom, and this is the escape hatch for exactly the person the |
| 877 | list is failing. One quiet line; the label carries the action, so | 915 | list is failing. One quiet line; the label carries the action, so |
| 878 | the chips stay bare format names. */} | 916 | the chips stay bare format names. */} |
| 879 | <div className="mb-8 flex flex-wrap items-center gap-x-3 gap-y-2"> | 917 | <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> | 918 | <span className="shrink-0 text-xs text-muted-foreground">Start from scratch:</span> |
| 881 | <div className="flex shrink-0 items-center gap-1.5"> | 919 | <div className="flex shrink-0 items-center gap-1.5"> |
| 882 | {integrationPlugins.map( | 920 | {integrationPlugins.map( |
| 883 | (plugin: IntegrationPlugin): ReactNode => ( | 921 | (plugin: IntegrationPlugin): ReactNode => ( |
| 884 | // oxlint-disable-next-line react/forbid-elements -- a chip, not a Button variant | 922 | // oxlint-disable-next-line react/forbid-elements -- a chip, not a Button variant |
| 885 | <button | 923 | <button |
| 886 | key={plugin.key} | 924 | key={plugin.key} |
| 887 | type="button" | 925 | type="button" |
| 888 | aria-label={`New ${plugin.label} integration from scratch`} | 926 | aria-label={`New ${plugin.label} integration from scratch`} |
| 889 | onClick={() => { | 927 | onClick={() => { |
| 890 | trackEvent("integration_add_started", { plugin_key: plugin.key, via: "manual" }); | 928 | trackEvent("integration_add_started", { |
| 891 | void navigate({ | 929 | plugin_key: plugin.key, |
| 892 | to: "/{-$orgSlug}/integrations/add/$pluginKey", | 930 | via: "manual", |
| 893 | params: { pluginKey: plugin.key }, | 931 | }); |
| 894 | }); | 932 | void navigate({ |
| 895 | }} | 933 | to: "/{-$orgSlug}/integrations/add/$pluginKey", |
| 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" | 934 | params: { pluginKey: plugin.key }, |
| 897 | > | 935 | }); |
| 898 | <PlusIcon className="size-3" aria-hidden /> | 936 | }} |
| 899 | {plugin.label} | 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" |
| 900 | </button> | 938 | > |
| 901 | ), | 939 | <PlusIcon className="size-3" aria-hidden /> |
| 902 | )} | 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 | </div> | 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 ? ( | 950 | {error ? ( |
| 910 | <p role="alert" className="mb-4 text-xs text-destructive"> | 951 | <p role="alert" className="mb-4 text-xs text-destructive"> |
| 911 | {error} | 952 | {error} |
| 912 | </p> | 953 | </p> |
| 913 | ) : null} | 954 | ) : null} |
| 955 | </div> | ||
| 914 | 956 | ||
| 915 | {loading ? ( | 957 | <div className="min-h-0 flex-1 overflow-y-auto"> |
| 916 | <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"> | 958 | <div className="mx-auto w-full max-w-4xl px-6 pb-10 lg:px-8"> |
| 917 | {Array.from({ length: 9 }).map((_, index) => ( | 959 | {loading ? ( |
| 918 | <CardSkeleton key={index} index={index} /> | 960 | <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"> |
| 919 | ))} | 961 | {Array.from({ length: 9 }).map((_, index) => ( |
| 920 | </div> | 962 | <CardSkeleton key={index} index={index} /> |
| 921 | ) : results.length === 0 ? ( | 963 | ))} |
| 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} /> | ||
| 940 | </div> | 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 | </div> | 988 | </div> |
| 943 | )} | 989 | </div> |
| 944 | </PageContainer> | 990 | </div> |
| 945 | ); | 991 | ); |
| 946 | } | 992 | } |