Skip to content

Commit

Merge branch 'worktree-agent-a11ea05ad7db44298'

syntaqxcommitted Parents4e2c97da7ba66aBrowse files
6 files+270−610/6 viewed
+73−48
2525 import { type ConnectorScope, type ConnectorView, connectorPath } from "@g1t/contracts/connectors";
2626
2727 import { AI_PROVIDERS } from "../lib/ai-providers";
28+import { type BrandIcon, brandIcon } from "../lib/brand-icons";
2829 import { CONTACT } from "../lib/legal";
2930 import {
3031 type CategoryFilter,
4243 import { cn } from "../lib/cn";
4344 import { integrationsSection } from "../lib/integration-sections";
4445 import { Badge } from "./ui/badge";
46+import { Button } from "./ui/button";
4547 import { Hint } from "./ui/hint";
46−import { Input } from "./ui";
48+import { Input, InputAddon, InputGroup } from "./ui/input";
4749 import { Card } from "./ui/card";
4850
4951 const CATEGORY_ICONS: Record<CategoryFilter, ReactNode> = {
7173 linear: "#8b8ff0",
7274 };
7375
74−/** A provider's mark: its initials in its colour, or an icon for the generic ones. */
75−export function ProviderMark({ provider, size = 32 }: { provider: Provider; size?: number }) {
76− const entry = AI_PROVIDERS[provider];
77− const color = entry?.color ?? PROVIDER_COLORS[provider] ?? "#a1a1aa";
78− const content =
79− provider === "webhook" ? (
80− <Webhook size={size * 0.5} />
81− ) : provider.endsWith("_endpoint") ? (
82− <Bot size={size * 0.5} />
83− ) : (
84− entry?.mark || PROVIDERS[provider].label[0]
85− );
76+/**
77+ * A mark's tile: a brand's icon, or letters or an icon, on a wash of
78+ * `color`. With no colour (a brand drawn in black) it is drawn in the text
79+ * colour on a neutral wash, so it reads in both themes.
80+ */
81+function MarkTile({ color, size, children }: { color: string | null; size: number; children: ReactNode }) {
82+ const tint = color ?? "var(--color-fg)";
8683 return (
8784 <span
8885 aria-hidden="true"
9188 width: size,
9289 height: size,
9390 borderRadius: size * 0.28,
94− fontSize: size * (String(content).length > 1 ? 0.34 : 0.42),
95− background: `color-mix(in srgb, ${color} 16%, transparent)`,
96− // Toward the text colour, so the letters read on the tile in both themes.
97− color: `color-mix(in srgb, ${color} 60%, var(--color-fg))`,
98− boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${color} 30%, transparent)`,
91+ fontSize: size * (typeof children === "string" && children.length > 1 ? 0.34 : 0.42),
92+ background: `color-mix(in srgb, ${tint} ${color ? 16 : 8}%, transparent)`,
93+ // Toward the text colour, so the mark reads on the tile in both themes.
94+ color: color ? `color-mix(in srgb, ${color} 60%, var(--color-fg))` : "var(--color-fg)",
95+ boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${tint} ${color ? 30 : 14}%, transparent)`,
9996 }}
10097 >
101− {content}
98+ {children}
10299 </span>
103100 );
104101 }
105102
103+/** A brand's icon, from Simple Icons, in the tile's colour. */
104+function BrandGlyph({ icon, size }: { icon: BrandIcon; size: number }) {
105+ return (
106+ <svg viewBox="0 0 24 24" width={size * 0.5} height={size * 0.5} fill="currentColor">
107+ <path d={icon.path} />
108+ </svg>
109+ );
110+}
111+
112+/** A provider's mark: its brand's icon, an icon for the generic ones, or its initials in its colour. */
113+export function ProviderMark({ provider, size = 32 }: { provider: Provider; size?: number }) {
114+ const brand = provider === "webhook" ? null : brandIcon(provider.replace(/_/g, "-"));
115+ if (brand) {
116+ return (
117+ <MarkTile color={brand.color} size={size}>
118+ <BrandGlyph icon={brand} size={size} />
119+ </MarkTile>
120+ );
121+ }
122+ const entry = AI_PROVIDERS[provider];
123+ const content =
124+ provider === "webhook" ? (
125+ <Webhook size={size * 0.5} />
126+ ) : provider.endsWith("_endpoint") ? (
127+ <Bot size={size * 0.5} />
128+ ) : (
129+ entry?.mark || monogram(PROVIDERS[provider].label)
130+ );
131+ return (
132+ <MarkTile color={entry?.color ?? PROVIDER_COLORS[provider] ?? "#a1a1aa"} size={size}>
133+ {content}
134+ </MarkTile>
135+ );
136+}
137+
106138 /** Providers to choose from, as compact tiles that open their form. */
107139 export function ProviderTiles({
108140 providers,
140172 );
141173 }
142174
143−/** A connector's tile: its provider's mark, or its letters on a steady colour. No logos. */
175+/** A connector's tile: its brand's icon, its provider's mark, or its letters on a steady colour. */
144176 export function ConnectorMark({ view, size = 36 }: { view: Pick<ConnectorView, "id" | "name" | "provider">; size?: number }) {
177+ const brand = brandIcon(view.id);
178+ if (brand) {
179+ return (
180+ <MarkTile color={brand.color} size={size}>
181+ <BrandGlyph icon={brand} size={size} />
182+ </MarkTile>
183+ );
184+ }
145185 if (view.provider) return <ProviderMark provider={view.provider} size={size} />;
146− const color = tileColor(view.id);
147− const letters = monogram(view.name);
186+ // Outgoing webhooks have no brand: the same webhook mark as incoming ones.
187+ if (view.id === "webhooks") return <ProviderMark provider="webhook" size={size} />;
148188 return (
149− <span
150− aria-hidden="true"
151− className="inline-flex shrink-0 items-center justify-center font-semibold tracking-tight"
152− style={{
153− width: size,
154− height: size,
155− borderRadius: size * 0.28,
156− fontSize: size * (letters.length > 1 ? 0.34 : 0.42),
157− background: `color-mix(in srgb, ${color} 16%, transparent)`,
158− // Toward the text colour, so the letters read in both themes.
159− color: `color-mix(in srgb, ${color} 60%, var(--color-fg))`,
160− boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${color} 30%, transparent)`,
161− }}
162− >
163− {letters}
164− </span>
189+ <MarkTile color={tileColor(view.id)} size={size}>
190+ {monogram(view.name)}
191+ </MarkTile>
165192 );
166193 }
167194
258285 </nav>
259286
260287 <div className="mt-5 min-w-0 md:mt-0">
261− <div className="relative">
262− <Search size={15} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
288+ <InputGroup>
289+ <InputAddon className="pr-0">
290+ <Search size={15} />
291+ </InputAddon>
263292 <Input
264293 type="search"
265294 value={query}
266295 onChange={(event) => setQuery(event.target.value)}
267296 placeholder={category === "all" ? "Search integrations" : `Search all integrations, not just ${categoryTitle(category)}`}
268297 aria-label="Search integrations"
269− className="pl-9"
270298 />
271− </div>
299+ </InputGroup>
272300
273301 {empty && (
274302 <Card asChild tone="plain" className="mt-8 border-dashed px-4 py-8 text-center text-sm text-muted">
423451 <div className="mt-auto flex items-end justify-between gap-3 pt-3">
424452 <p className="text-[0.6875rem] text-faint">{also}</p>
425453 {soon ? (
426− <a
427− href={askHref({ connector: view, scope, workspace, address: CONTACT.support })}
428− className="relative z-10 shrink-0 rounded-md border border-line px-2.5 py-1 text-xs font-medium text-fg/80 hover:border-line-strong hover:bg-raised hover:text-fg"
429− >
430− Ask for this
431− </a>
454+ <Button asChild variant="outline" size="xs" className="relative z-10">
455+ <a href={askHref({ connector: view, scope, workspace, address: CONTACT.support })}>Ask for this</a>
456+ </Button>
432457 ) : to ? (
433458 <span className="flex shrink-0 items-center gap-0.5 text-xs font-medium text-accent">
434459 {state ? (canManage ? "Manage" : "View") : canManage ? "Set up" : "View"}
+162−0
1+/**
2+ * Connectors' brand marks, from Simple Icons, by connector id
3+ * (@g1t/contracts/connectors). A connector without one here keeps its
4+ * letters. Named imports, so only these icons are bundled.
5+ */
6+import {
7+ type SimpleIcon,
8+ si1password,
9+ siAnthropic,
10+ siAsana,
11+ siAtlassian,
12+ siBetterstack,
13+ siBitbucket,
14+ siBox,
15+ siBuildkite,
16+ siCircleci,
17+ siClickup,
18+ siCloudflare,
19+ siCoda,
20+ siDatadog,
21+ siDeepseek,
22+ siDiscord,
23+ siDocker,
24+ siDropbox,
25+ siFigma,
26+ siFlydotio,
27+ siGithub,
28+ siGitlab,
29+ siGmail,
30+ siGooglebigquery,
31+ siGooglecalendar,
32+ siGooglecloud,
33+ siGoogledocs,
34+ siGoogledrive,
35+ siGooglegemini,
36+ siGooglemeet,
37+ siGrafana,
38+ siHubspot,
39+ siHuggingface,
40+ siIntercom,
41+ siJira,
42+ siKubernetes,
43+ siLinear,
44+ siMiro,
45+ siMistralai,
46+ siModelcontextprotocol,
47+ siNeon,
48+ siNetlify,
49+ siNewrelic,
50+ siNotion,
51+ siOkta,
52+ siOpenrouter,
53+ siOpsgenie,
54+ siPagerduty,
55+ siPaypal,
56+ siPostgresql,
57+ siPosthog,
58+ siSentry,
59+ siShortcut,
60+ siSnowflake,
61+ siSnyk,
62+ siStorybook,
63+ siStripe,
64+ siSupabase,
65+ siTrello,
66+ siVercel,
67+ siZapier,
68+ siZendesk,
69+ siZoom,
70+} from "simple-icons";
71+
72+const BRAND_ICONS: Record<string, SimpleIcon> = {
73+ github: siGithub,
74+ "github-mcp": siGithub,
75+ gitlab: siGitlab,
76+ bitbucket: siBitbucket,
77+ circleci: siCircleci,
78+ buildkite: siBuildkite,
79+ "docker-hub": siDocker,
80+ linear: siLinear,
81+ "linear-mcp": siLinear,
82+ jira: siJira,
83+ asana: siAsana,
84+ shortcut: siShortcut,
85+ clickup: siClickup,
86+ trello: siTrello,
87+ discord: siDiscord,
88+ zoom: siZoom,
89+ "google-meet": siGooglemeet,
90+ "google-calendar": siGooglecalendar,
91+ gmail: siGmail,
92+ notion: siNotion,
93+ atlassian: siAtlassian,
94+ "google-docs": siGoogledocs,
95+ zapier: siZapier,
96+ coda: siCoda,
97+ sentry: siSentry,
98+ "sentry-mcp": siSentry,
99+ datadog: siDatadog,
100+ pagerduty: siPagerduty,
101+ opsgenie: siOpsgenie,
102+ grafana: siGrafana,
103+ "new-relic": siNewrelic,
104+ "better-stack": siBetterstack,
105+ cloudflare: siCloudflare,
106+ "cloudflare-docs": siCloudflare,
107+ "google-cloud": siGooglecloud,
108+ vercel: siVercel,
109+ netlify: siNetlify,
110+ "fly-io": siFlydotio,
111+ kubernetes: siKubernetes,
112+ anthropic: siAnthropic,
113+ gemini: siGooglegemini,
114+ mistral: siMistralai,
115+ deepseek: siDeepseek,
116+ openrouter: siOpenrouter,
117+ mcp: siModelcontextprotocol,
118+ "hugging-face": siHuggingface,
119+ postgresql: siPostgresql,
120+ snowflake: siSnowflake,
121+ bigquery: siGooglebigquery,
122+ posthog: siPosthog,
123+ supabase: siSupabase,
124+ neon: siNeon,
125+ zendesk: siZendesk,
126+ intercom: siIntercom,
127+ hubspot: siHubspot,
128+ stripe: siStripe,
129+ paypal: siPaypal,
130+ figma: siFigma,
131+ miro: siMiro,
132+ storybook: siStorybook,
133+ okta: siOkta,
134+ "1password": si1password,
135+ snyk: siSnyk,
136+ "google-drive": siGoogledrive,
137+ dropbox: siDropbox,
138+ box: siBox,
139+};
140+
141+/** A connector's brand mark: its SVG path, and the colour to draw it in. */
142+export type BrandIcon = { path: string; title: string; color: string | null };
143+
144+/** Relative luminance of a `RRGGBB` colour, 0 (black) to 1 (white). */
145+function luminance(hex: string): number {
146+ const [r, g, b] = [0, 2, 4].map((at) => {
147+ const channel = parseInt(hex.slice(at, at + 2), 16) / 255;
148+ return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
149+ });
150+ return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
151+}
152+
153+/**
154+ * The brand mark for connector `id`, or null. `color` is the brand's own,
155+ * or null when it is black or nearly (GitHub, Notion, Vercel): those are
156+ * drawn in the text colour, so they read on the dark theme as on the light.
157+ */
158+export function brandIcon(id: string): BrandIcon | null {
159+ const icon = BRAND_ICONS[id];
160+ if (!icon) return null;
161+ return { path: icon.path, title: icon.title, color: luminance(icon.hex) < 0.04 ? null : `#${icon.hex}` };
162+}
+5−1
4444
4545 test("search finds by name, description, tag and keyword, across every category", () => {
4646 const found = arrange(workspace, { category: "design", query: "sentry", connected: none });
47− assert.deepEqual(found.available.map((view) => view.id), ["sentry", "sentry-mcp"], "a search is not held to the category");
47+ assert.deepEqual(found.available.map((view) => view.id), ["sentry"], "a search is not held to the category");
48+ assert.deepEqual(found.soon.map((view) => view.id), ["sentry-mcp"], "what is coming sorts after what works");
4849 assert.ok(matchesQuery(personal.find((view) => view.id === "google-calendar")!, "in a meeting"));
4950 assert.ok(matchesQuery(workspace.find((view) => view.id === "anthropic")!, "claude"));
5051 assert.ok(matchesQuery(workspace.find((view) => view.id === "incident-io")!, "incidentio"));
7475 assert.equal(monogram("Sentry"), "S");
7576 assert.equal(monogram("incident.io"), "II");
7677 assert.equal(monogram("1Password"), "1P");
78+ assert.equal(monogram("GitHub (MCP)"), "GM", "punctuation is not a letter");
79+ assert.equal(monogram("Linear (MCP)"), "LM");
80+ assert.equal(monogram("Atlassian (Jira and Confluence)"), "AJ");
7781 assert.equal(tileColor("figma"), tileColor("figma"));
7882 });
7983
+6−2
101101 })).filter((group) => group.views.length > 0);
102102 }
103103
104−/** The letters on a connector's tile: two words' initials, or a name's capitals ("PagerDuty" → "PD"). */
104+/**
105+ * The letters on a connector's tile: two words' initials, or a name's
106+ * capitals ("PagerDuty" → "PD"). Only letters and digits count, so
107+ * "GitHub (MCP)" is "GM", never "G(".
108+ */
105109 export function monogram(name: string): string {
106− const words = name.split(/[\s.\-/]+/).filter((word) => /[A-Za-z0-9]/.test(word));
110+ const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
107111 if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase();
108112 const word = words[0] ?? name;
109113 const capitals = word.slice(1).match(/[A-Z0-9]/);
+16−0
3737 }
3838 });
3939
40+test("only what g1t sets up end to end is available; the docs' Available today lists the same", () => {
41+ const available = (scope: "workspace" | "personal") =>
42+ connectorsFor(scope)
43+ .filter((view) => view.status === "available")
44+ .map((view) => view.id)
45+ .sort();
46+ const models = Object.entries(PROVIDERS)
47+ .filter(([, provider]) => provider.kind === "models")
48+ .map(([id]) => id.replace(/_/g, "-"));
49+ assert.deepEqual(available("workspace"), ["alerts-webhook", "datadog", "github", "jira", "linear", "sentry", "webhooks", ...models].sort());
50+ assert.deepEqual(available("personal"), ["github", "mcp"]);
51+ for (const connector of CONNECTORS) {
52+ if (connector.mcp) assert.equal(connector.status, "soon", `${connector.id}: nothing connects to MCP servers yet`);
53+ }
54+});
55+
4056 test("a connector shows its personal side to you", () => {
4157 const github = CONNECTORS.find((c) => c.id === "github")!;
4258 const mine = connectorView(github, "personal")!;
+8−10
7373 /** More words search should find it by. */
7474 keywords?: string[];
7575 /**
76− * The remote MCP server behind it, when the tool publishes one: g1t
77− * connects to it (docs.g1t.sh/guides/customize/, "Connectors"), signs in
78− * the way it asks (`oauth`: the browser; `header`: a key an owner
79− * pastes; `none`), lists its tools, and gives them to agents.
76+ * The remote MCP server behind it, when the tool publishes one, and how
77+ * it signs in (`oauth`: the browser; `header`: a key an owner pastes;
78+ * `none`). Its connector is soon: see `mcp()` below.
8079 */
8180 mcp?: {
8281 url: string;
141140 keywords: ["model", "llm", ...keywords],
142141 };
143142 }
144−
145−/** Where an MCP-backed connector is connected: Customize's Connectors page, with the directory entry chosen. */
146−export const MCP_CONNECT_PATH = "/:workspace/-/customize/connectors/new?directory=";
147143
148144 /**
149− * A tool with a remote MCP server: available now, connected from Customize
150− * (an owner signs in or pastes a key), its tools then given to agents.
145+ * A tool with a remote MCP server. Soon: Customize, where an owner
146+ * connected one, was retired with g1t's hosted agents, so nothing in g1t
147+ * connects to these servers today. The server is kept for when something
148+ * does; until then each person's own harness connects to it directly.
151149 */
152150 function mcp(
153151 id: string,
158156 capabilities: ConnectorCapability[] = ["Agents can read", "Agents can act"],
159157 keywords: string[] = [],
160158 ): Connector {
161− return { id, name, category, description, scopes: W, status: "available", href: { workspace: `${MCP_CONNECT_PATH}${id}` }, capabilities, keywords: ["mcp", ...keywords], mcp: server };
159+ return { id, name, category, description, scopes: W, status: "soon", capabilities, keywords: ["mcp", ...keywords], mcp: server };
162160 }
163161
164162 /** A planned connector. */