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
1919 import { trackEvent } from "../api/analytics";
2020 import { Button } from "../components/button";
2121 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";
2328 import { Skeleton } from "../components/skeleton";
2429 import { useExecutorDocumentTitle } from "../lib/document-title";
2530 import {
@@ -804,8 +809,28 @@ export function IntegrationBrowsePage() { · 778 unmodified lines
804809 row.name.toLowerCase().includes(text) ||
805810 String(row.slug).includes(text)),
806811 )
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 {
809834 key: `installed-${String(row.slug)}`,
810835 testId: `installed-${String(row.slug)}`,
811836 title: row.name,
@@ -813,134 +838,155 @@ export function IntegrationBrowsePage() { · 1 unmodified line
813838 ...(row.description && row.description !== row.name
814839 ? { description: tidyDescription(row.description) }
815840 : {}),
841+ ...(iconUrl ? { iconUrl } : {}),
816842 onSelect: () => {},
817843 added: true,
818844 viewSlug: String(row.slug),
819845 busy: false,
820− }),
821− );
846+ };
847+ });
822848 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+ ]);
824858
825859 // Presets are local, so a list that already has them is not empty — show
826860 // skeletons only when there is genuinely nothing on screen yet.
827861 const loading = catalog.loading && results.length === 0;
828862
829863 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">
831868 {integrationPlugins.map((plugin) => (
832869 <QuickAddBridge key={plugin.key} plugin={plugin} register={registerQuickAdd} />
833870 ))}
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}
862911 </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>
874912
875− {/* Above the results, not below: an endless list has no reachable
913+ {/* Above the results, not below: an endless list has no reachable
876914 bottom, and this is the escape hatch for exactly the person the
877915 list is failing. One quiet line; the label carries the action, so
878916 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&apos;t find it? Paste its URL above.
947+ </span>
903948 </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>
908949
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>
914956
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+ ))}
940964 </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+ )}
942988 </div>
943− )}
944− </PageContainer>
989+ </div>
990+ </div>
945991 );
946992 }