History

Picker cards share the sidebar's icon cascade; only the grid scrolls

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.

Rhys Sullivan committed 2 months ago

Commit cc033851 parent 6931ee11 file · +154 −108Browse files

Unified · Split

packages/react/src/pages/integration-browse.tsx+154−108
@@ -19,7 +19,12 @@ import { slugifyNamespace } from "../plugins/namespace"; · 18 unmodified lines
19import { trackEvent } from "../api/analytics";19import { trackEvent } from "../api/analytics";
20import { Button } from "../components/button";20import { Button } from "../components/button";
21import { Input } from "../components/input";21import { Input } from "../components/input";
22import { PageContainer, PageHeader } from "../components/page";22import { PageHeader } from "../components/page";
23import {
24 integrationFaviconUrl,
25 integrationInferredUrl,
26 integrationPresetIconUrl,
27} from "../components/integration-favicon";
23import { Skeleton } from "../components/skeleton";28import { Skeleton } from "../components/skeleton";
24import { useExecutorDocumentTitle } from "../lib/document-title";29import { useExecutorDocumentTitle } from "../lib/document-title";
25import {30import {
@@ -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.name838 ...(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 — show859 // 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 <PageHeader871 <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 <SearchIcon878 <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-hidden880 className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
844 />881 aria-hidden
845 <Input882 />
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" action895 disabled={detecting}
859 autoFocus896 // 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 reachable913 {/* Above the results, not below: an endless list has no reachable
876 bottom, and this is the escape hatch for exactly the person the914 bottom, and this is the escape hatch for exactly the person the
877 list is failing. One quiet line; the label carries the action, so915 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 variant922 // oxlint-disable-next-line react/forbid-elements -- a chip, not a Button variant
885 <button923 <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&apos;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&apos;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}