g1t/apps/web/app/routes/workspace/integrations.tsx

681 lines26,029 bytesCodeBlame
1import {
2 AlertTriangle,
3 CheckCircle2,
4 ChevronRight,
5 Cpu,
6 Siren,
7 Ticket,
8 X,
9} from "lucide-react";
10import { env } from "cloudflare:workers";
11import type { ReactNode } from "react";
12import { Form, Link, useNavigation } from "react-router";
13
14import {
15 type Connection,
16 type ConnectionConfig,
17 type Delivery,
18 MODEL_TASKS,
19 type ModelRoute,
20 type Provider,
21 type ProviderKind,
22 PROVIDERS,
23} from "@g1t/contracts";
24
25import type { Route } from "./+types/integrations";
26import { MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers";
27import { Button, CopyLine, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
28import { billing, integrations, repos } from "../../lib/services.server";
29import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
30
31export function meta({ params }: Route.MetaArgs) {
32 return [{ title: `Integrations · ${params.owner} · g1t` }];
33}
34
35const isProvider = (value: string | null): value is Provider => value != null && value in PROVIDERS;
36
37export async function loader({ params, context, request }: Route.LoaderArgs) {
38 const viewer = getViewer(context);
39 const role = roleIn(viewer, params.owner);
40 if (!role) throw new Response(null, { status: 404 });
41 const slug = params.owner.toLowerCase();
42 const [connections, listed, account, access, routes] = await Promise.all([
43 integrations.list(slug, viewer),
44 repos.list(viewer, { namespace: slug }),
45 billing.account(slug, viewer),
46 env.RUNNER.modelAccess(slug),
47 integrations.routes(slug, viewer),
48 ]);
49 const all = unwrap(connections);
50 // What each alert source has sent lately, to see it is wired up.
51 const deliveries: Record<string, Delivery[]> = {};
52 await Promise.all(
53 all
54 .filter((connection) => connection.kind === "alerts")
55 .map(async (connection) => {
56 const found = await integrations.deliveries(slug, viewer, connection.id);
57 deliveries[connection.id] = found.ok ? found.value.slice(0, 5) : [];
58 }),
59 );
60 const adding = new URL(request.url).searchParams.get("add");
61 return {
62 slug,
63 role,
64 connections: all,
65 deliveries,
66 repos: listed.map((repo) => `${repo.namespace}/${repo.name}`),
67 adding: isProvider(adding) ? adding : null,
68 hostedOpen: access.hosted,
69 routes: routes.ok ? routes.value : [],
70 feeMicros: account.ok ? account.value.orchestrationFeeMicros : 100_000,
71 free: account.ok ? Boolean(account.value.status.free) : false,
72 marginPercent: account.ok ? account.value.marginPercent : 20,
73 };
74}
75
76function text(form: FormData, name: string): string | undefined {
77 const value = String(form.get(name) ?? "").trim();
78 return value || undefined;
79}
80
81/** The settings a form describes, for the provider it is for. */
82function configFrom(form: FormData): ConnectionConfig {
83 const keys = text(form, "keys");
84 return {
85 repo: text(form, "repo"),
86 assign: form.get("assign") === "on",
87 label: text(form, "label"),
88 writeBack: form.get("writeBack") !== "off",
89 organization: text(form, "organization"),
90 site: text(form, "site"),
91 email: text(form, "email"),
92 keys: keys ? keys.split(/[\s,]+/).filter(Boolean) : [],
93 baseUrl: text(form, "baseUrl"),
94 authHeader: text(form, "authHeader"),
95 model: text(form, "model"),
96 };
97}
98
99export async function action({ request, params, context }: Route.ActionArgs) {
100 assertSameOrigin(request);
101 const user = requireUser(context, request);
102 const slug = params.owner.toLowerCase();
103 const form = await request.formData();
104 const intent = form.get("intent");
105 const id = String(form.get("id") ?? "");
106 if (intent === "disconnect") {
107 const removed = await integrations.disconnect(user, slug, id);
108 return { error: removed.ok ? null : removed.error.message };
109 }
110 if (intent === "test") {
111 const tested = await integrations.test(user, slug, id);
112 return tested.ok ? { tested: { id, ...tested.value } } : { error: tested.error.message };
113 }
114 if (intent === "routes") {
115 const routes: ModelRoute[] = [];
116 for (const task of MODEL_TASKS) {
117 const choice = String(form.get(`route-${task}`) ?? "");
118 if (!choice) continue;
119 if (choice === "g1t") routes.push({ task, connectionId: null, model: null });
120 else {
121 const [connectionId, model] = choice.split("::");
122 routes.push({ task, connectionId, model: model || null });
123 }
124 }
125 const saved = await integrations.setRoutes(user, slug, routes);
126 return saved.ok ? { routed: true } : { error: saved.error.message };
127 }
128 if (intent === "update") {
129 const updated = await integrations.update(user, slug, id, {
130 signingSecret: text(form, "signingSecret"),
131 secret: text(form, "secret"),
132 });
133 return { error: updated.ok ? null : updated.error.message };
134 }
135 const provider = String(form.get("provider") ?? "");
136 if (!isProvider(provider)) return { error: "Choose what to connect." };
137 const connected = await integrations.connect(user, slug, {
138 provider,
139 name: text(form, "name"),
140 config: configFrom(form),
141 secret: text(form, "secret"),
142 signingSecret: text(form, "signingSecret"),
143 });
144 return connected.ok ? { connected: connected.value } : { error: connected.error.message, provider };
145}
146
147const KIND_INFO: Record<ProviderKind, { title: string; icon: ReactNode; blurb: string }> = {
148 models: {
149 title: "Model providers",
150 icon: <Cpu size={16} />,
151 blurb: "Connect as many as you use, then choose which model does which work, and so who pays for it.",
152 },
153 alerts: {
154 title: "Alerts",
155 icon: <Siren size={16} />,
156 blurb: "Problems your monitoring finds become issues, and agents can start on them before anyone looks.",
157 },
158 tracker: {
159 title: "Trackers",
160 icon: <Ticket size={16} />,
161 blurb: "Tickets agents can read when work mentions them, that people can import as issues, and that hear back when the work lands.",
162 },
163};
164
165const PROVIDER_BLURB: Partial<Record<Provider, string>> = {
166 sentry: "New errors open issues, with the stack trace. Resolved in Sentry when the fix merges.",
167 datadog: "Monitors that trigger open issues. Recoveries are noted on them.",
168 webhook: "Anything that can send signed JSON: PagerDuty, Grafana, your own scripts.",
169 jira: "Agents read TECH-1234 when work mentions it. Import tickets; they hear back.",
170 linear: "Agents read ENG-42 when work mentions it. Import issues; they hear back.",
171};
172
173function dollars(micros: number): string {
174 return `$${(micros / 1_000_000).toFixed(2)}`;
175}
176
177export default function WorkspaceIntegrations({ loaderData, actionData }: Route.ComponentProps) {
178 const { slug, role, connections, deliveries, repos: repoNames, adding, feeMicros, free, marginPercent, hostedOpen, routes } =
179 loaderData;
180 const owner = role === "owner";
181 const busy = useNavigation().state === "submitting";
182 const modelConnections = connections.filter((connection) => connection.kind === "models");
183 const justConnected = actionData && "connected" in actionData ? actionData.connected : null;
184 const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null;
185 const error = (actionData && "error" in actionData ? actionData.error : null) ?? null;
186
187 return (
188 <div>
189 {justConnected && <Connected connected={justConnected} />}
190 <div className="mt-4">
191 <ErrorText>{error}</ErrorText>
192 </div>
193
194 {/* Models -------------------------------------------------------------- */}
195 <Section kind="models" first>
196 {!hostedOpen && modelConnections.length === 0 && (
197 <div className="mb-4 flex items-center gap-3 rounded-xl border border-warn/30 bg-warn/5 p-4">
198 <span className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-warn/10 text-warn ring-1 ring-warn/30">
199 <AlertTriangle size={16} />
200 </span>
201 <p className="text-sm text-muted">
202 <span className="font-medium text-fg">Choose how your agents reach a model.</span> g1t's hosted models
203 are not open to {slug} yet. Connect a provider of your own below and your agents start at once,
204 billed by that provider.
205 </p>
206 </div>
207 )}
208 <Connections list={modelConnections} owner={owner} busy={busy} tested={tested} deliveries={deliveries} />
209 {modelConnections.length === 0 && hostedOpen && (
210 <p className="flex items-center gap-2 rounded-xl border border-line bg-surface px-4 py-3 text-sm text-muted">
211 <CheckCircle2 size={15} className="shrink-0 text-merged" />
212 All work runs on g1t's models, charged to your credit at cost plus {marginPercent}%. Connect a provider of
213 your own to choose models and pay for them there.
214 </p>
215 )}
216 {modelConnections.length > 0 && (
217 <Routing
218 connections={modelConnections}
219 routes={routes}
220 hostedOpen={hostedOpen}
221 marginPercent={marginPercent}
222 owner={owner}
223 busy={busy}
224 saved={actionData != null && "routed" in actionData}
225 />
226 )}
227 <p className="mt-3 text-xs text-faint">
228 On your own providers, their bills are yours.{" "}
229 {free
230 ? `g1t charges nothing while it is being built out; once pricing starts, ${dollars(feeMicros)} a run for the sandbox and orchestration.`
231 : `g1t charges ${dollars(feeMicros)} a run for the sandbox and orchestration.`}{" "}
232 Keys go only from g1t's model proxy to the provider: the agent's
233 sandbox holds a token that dies with the run.
234 </p>
235 {owner && <ModelCatalog slug={slug} adding={adding} />}
236 {adding && PROVIDERS[adding].kind === "models" && owner && (
237 <AddForm
238 provider={adding}
239 slug={slug}
240 repos={repoNames}
241 busy={busy}
242 error={actionData && "provider" in actionData ? error : null}
243 />
244 )}
245 </Section>
246
247 {/* Alerts -------------------------------------------------------------- */}
248 <Section kind="alerts">
249 <Connections
250 list={connections.filter((c) => c.kind === "alerts")}
251 owner={owner}
252 busy={busy}
253 tested={tested}
254 deliveries={deliveries}
255 />
256 {owner && <Choices kind="alerts" slug={slug} adding={adding} />}
257 {adding && PROVIDERS[adding].kind === "alerts" && owner && (
258 <AddForm
259 provider={adding}
260 slug={slug}
261 repos={repoNames}
262 busy={busy}
263 error={actionData && "provider" in actionData ? error : null}
264 />
265 )}
266 </Section>
267
268 {/* Trackers ------------------------------------------------------------ */}
269 <Section kind="tracker">
270 <Connections
271 list={connections.filter((c) => c.kind === "tracker")}
272 owner={owner}
273 busy={busy}
274 tested={tested}
275 deliveries={deliveries}
276 />
277 {owner && <Choices kind="tracker" slug={slug} adding={adding} />}
278 {adding && PROVIDERS[adding].kind === "tracker" && owner && (
279 <AddForm
280 provider={adding}
281 slug={slug}
282 repos={repoNames}
283 busy={busy}
284 error={actionData && "provider" in actionData ? error : null}
285 />
286 )}
287 </Section>
288
289 {!owner && <p className="mt-8 text-sm text-muted">An owner of {slug} can add and remove integrations.</p>}
290 </div>
291 );
292}
293
294function Section({ kind, first, children }: { kind: ProviderKind; first?: boolean; children: ReactNode }) {
295 const info = KIND_INFO[kind];
296 return (
297 <section className={first ? "" : "mt-12"}>
298 <h3 className="flex items-center gap-2 text-sm font-medium">
299 <span className="text-muted">{info.icon}</span>
300 {info.title}
301 </h3>
302 <p className="mt-1 mb-4 text-sm text-muted">{info.blurb}</p>
303 {children}
304 </section>
305 );
306}
307
308function Connections({
309 list,
310 owner,
311 busy,
312 tested,
313 deliveries,
314}: {
315 list: Connection[];
316 owner: boolean;
317 busy: boolean;
318 tested: { id: string; ok: boolean; message: string } | null;
319 deliveries: Record<string, Delivery[]>;
320}) {
321 if (list.length === 0) return null;
322 return (
323 <ul className="mb-4 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
324 {list.map((connection) => (
325 <li key={connection.id} className="p-4">
326 <ConnectionRow
327 connection={connection}
328 owner={owner}
329 busy={busy}
330 tested={tested}
331 deliveries={deliveries[connection.id] ?? []}
332 />
333 </li>
334 ))}
335 </ul>
336 );
337}
338
339/** Just the host of an address, which is what tells connections apart. */
340function host(url: string | undefined): string | undefined {
341 if (!url) return undefined;
342 try {
343 return new URL(url).host;
344 } catch {
345 return url;
346 }
347}
348
349function ConnectionRow({
350 connection,
351 owner,
352 busy,
353 tested,
354 deliveries,
355}: {
356 connection: Connection;
357 owner: boolean;
358 busy: boolean;
359 tested: { id: string; ok: boolean; message: string } | null;
360 deliveries: Delivery[];
361}) {
362 const { config } = connection;
363 const facts = [
364 PROVIDERS[connection.provider].label,
365 config.repo && `issues in ${config.repo}`,
366 config.assign && "agents start at once",
367 config.organization,
368 host(config.site),
369 host(config.baseUrl),
370 config.model && `default ${config.model}`,
371 connection.models.length > 0 && `${connection.models.length} models`,
372 config.keys?.length ? config.keys.join(", ") : null,
373 connection.secretHint && `key ${connection.secretHint}`,
374 ].filter(Boolean);
375 const waitingForSecret = connection.provider === "sentry" && !deliveries.length && !connection.lastUsedAt;
376 return (
377 <div>
378 <div className="flex items-center gap-3">
379 <ProviderMark provider={connection.provider} />
380 <div className="min-w-0 grow">
381 <p className="truncate text-sm font-medium">{connection.name}</p>
382 <p className="truncate text-xs text-muted">{facts.join(" · ")}</p>
383 </div>
384 {connection.lastUsedAt && (
385 <span className="hidden shrink-0 text-xs text-faint sm:inline">
386 Used <TimeAgo at={connection.lastUsedAt} />
387 </span>
388 )}
389 {owner && (
390 <Form method="post" className="flex shrink-0 gap-2">
391 <input type="hidden" name="id" value={connection.id} />
392 <Button variant="quiet" type="submit" name="intent" value="test" disabled={busy}>
393 Test
394 </Button>
395 <Button variant="quiet" type="submit" name="intent" value="disconnect" disabled={busy} aria-label="Disconnect">
396 <X size={14} />
397 </Button>
398 </Form>
399 )}
400 </div>
401 {tested?.id === connection.id && (
402 <p className={`mt-3 text-sm ${tested.ok ? "text-accent" : "text-danger"}`}>{tested.message}</p>
403 )}
404 {connection.lastError && (
405 <p className="mt-3 flex items-start gap-2 text-xs text-warn">
406 <AlertTriangle size={13} className="mt-0.5 shrink-0" />
407 {connection.lastError}
408 </p>
409 )}
410 {connection.webhookUrl && (
411 <div className="mt-3">
412 <p className="mb-1.5 text-xs text-faint">Where it sends alerts</p>
413 <CopyLine text={connection.webhookUrl} />
414 </div>
415 )}
416 {owner && waitingForSecret && (
417 <Form method="post" className="mt-3 flex flex-wrap items-end gap-2">
418 <input type="hidden" name="intent" value="update" />
419 <input type="hidden" name="id" value={connection.id} />
420 <div className="min-w-64 grow">
421 <Field label="Client secret" hint="From the internal integration's page in Sentry, once it has this address. Leave it if it is already saved.">
422 <Input name="signingSecret" type="password" placeholder="Paste to replace" />
423 </Field>
424 </div>
425 <Button type="submit" variant="quiet" disabled={busy}>
426 Save
427 </Button>
428 </Form>
429 )}
430 {deliveries.length > 0 && (
431 <ul className="mt-3 space-y-1 text-xs">
432 {deliveries.map((delivery) => (
433 <li key={delivery.id} className="flex items-center gap-2 text-muted">
434 <span
435 className={`size-1.5 shrink-0 rounded-full ${
436 delivery.outcome === "refused"
437 ? "bg-danger"
438 : delivery.outcome === "ignored"
439 ? "bg-faint"
440 : "bg-accent"
441 }`}
442 />
443 <span className="font-mono text-faint">{delivery.event}</span>
444 <span className="min-w-0 truncate">{delivery.detail}</span>
445 {delivery.issue && (
446 <Link
447 to={`/${delivery.issue.replace("#", "/issues/")}`}
448 className="shrink-0 font-mono text-fg hover:underline"
449 >
450 {delivery.issue}
451 </Link>
452 )}
453 <span className="ml-auto shrink-0 text-faint">
454 <TimeAgo at={delivery.receivedAt} />
455 </span>
456 </li>
457 ))}
458 </ul>
459 )}
460 </div>
461 );
462}
463
464/** What a provider is for, in a line. */
465function blurb(provider: Provider): string {
466 return PROVIDER_BLURB[provider] ?? MODEL_CATALOG[provider]?.blurb ?? "";
467}
468
469function Choices({ kind, slug, adding }: { kind: ProviderKind; slug: string; adding: Provider | null }) {
470 const providers = (Object.keys(PROVIDERS) as Provider[]).filter((p) => PROVIDERS[p].kind === kind);
471 return (
472 <div className="mt-3">
473 <ProviderTiles providers={providers} slug={slug} adding={adding} blurb={blurb} />
474 </div>
475 );
476}
477
478function RepoField({ repos, required, hint }: { repos: string[]; required?: boolean; hint: string }) {
479 return (
480 <Field label="Repository" hint={hint}>
481 <select
482 name="repo"
483 required={required}
484 defaultValue={repos[0] ?? ""}
485 className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none hover:border-line-strong focus:border-accent-dim"
486 >
487 {!required && <option value="">None</option>}
488 {repos.map((repo) => (
489 <option key={repo} value={repo}>
490 {repo}
491 </option>
492 ))}
493 </select>
494 </Field>
495 );
496}
497
498function AssignField() {
499 return (
500 <label className="flex items-start gap-2.5 text-sm">
501 <input type="checkbox" name="assign" className="mt-1 accent-[var(--color-accent)]" />
502 <span>
503 <span className="font-medium">Put a g1t agent on each new issue</span>
504 <span className="block text-xs text-muted">
505 It opens a pull request, gets reviewed, and lands through your merge rules, before anyone has
506 to look. Agents run only where g1t agents are enabled.
507 </span>
508 </span>
509 </label>
510 );
511}
512
513function AddForm({
514 provider,
515 slug,
516 repos,
517 busy,
518 error,
519}: {
520 provider: Provider;
521 slug: string;
522 repos: string[];
523 busy: boolean;
524 error: string | null;
525}) {
526 const fields: Partial<Record<Provider, ReactNode>> = {
527 sentry: (
528 <>
529 <Field label="Organization" hint="Its slug, as in acme.sentry.io.">
530 <Input name="organization" required placeholder="acme" />
531 </Field>
532 <RepoField repos={repos} required hint="Where issues are opened." />
533 <Field label="Auth token" hint="The internal integration's token, with Issue & Event read and write. Lets g1t read stack traces and resolve issues. Optional, but recommended.">
534 <Input name="secret" type="password" />
535 </Field>
536 <Field label="Client secret" hint="You get this from Sentry after giving it this connection's address; you can add it on the next step.">
537 <Input name="signingSecret" type="password" />
538 </Field>
539 <AssignField />
540 </>
541 ),
542 datadog: (
543 <>
544 <RepoField repos={repos} required hint="Where issues are opened." />
545 <AssignField />
546 </>
547 ),
548 webhook: (
549 <>
550 <RepoField repos={repos} required hint="Where issues are opened." />
551 <AssignField />
552 </>
553 ),
554 jira: (
555 <>
556 <Field label="Site" hint="Your Jira's address.">
557 <Input name="site" type="url" required placeholder="https://acme.atlassian.net" />
558 </Field>
559 <Field label="Email" hint="The account the API token belongs to.">
560 <Input name="email" type="email" required autoComplete="off" />
561 </Field>
562 <Field label="API token" hint="From id.atlassian.com → Security → API tokens.">
563 <Input name="secret" type="password" required />
564 </Field>
565 <Field label="Project keys" hint="Optional. The projects this answers for, such as TECH, OPS. Empty answers for any key.">
566 <Input name="keys" placeholder="TECH, OPS" />
567 </Field>
568 </>
569 ),
570 linear: (
571 <>
572 <Field label="API key" hint="From Linear → Settings → Security & access → Personal API keys.">
573 <Input name="secret" type="password" required placeholder="lin_api_…" />
574 </Field>
575 <Field label="Team keys" hint="Optional. Such as ENG. Empty answers for any key.">
576 <Input name="keys" placeholder="ENG" />
577 </Field>
578 </>
579 ),
580 };
581 return (
582 <section id="add" className="mt-4 scroll-mt-20 rounded-xl border border-line bg-surface p-5">
583 <div className="flex items-center gap-3">
584 <ProviderMark provider={provider} />
585 <div className="grow">
586 <h3 className="font-medium">
587 Connect {provider.endsWith("_endpoint") ? `an ${PROVIDERS[provider].label}` : PROVIDERS[provider].label}
588 </h3>
589 <p className="text-xs text-muted">{blurb(provider)}</p>
590 </div>
591 <Link to={`/${slug}/-/integrations`} className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" aria-label="Close">
592 <X size={16} />
593 </Link>
594 </div>
595 <Form method="post" className="mt-5 grid max-w-xl gap-4">
596 <input type="hidden" name="provider" value={provider} />
597 {fields[provider] ?? <ModelProviderFields provider={provider} />}
598 <ErrorText>{error}</ErrorText>
599 <div>
600 <Button type="submit" disabled={busy}>
601 {busy ? "Connecting…" : "Connect"}
602 </Button>
603 </div>
604 </Form>
605 </section>
606 );
607}
608
609/** What to do next, right after connecting: shown once. */
610function Connected({ connected }: { connected: { connection: Connection; signingSecret: string | null } }) {
611 const { connection, signingSecret } = connected;
612 const url = connection.webhookUrl;
613 return (
614 <div className="mt-6 rounded-xl border border-accent-dim/60 bg-accent/5 p-5">
615 <p className="flex items-center gap-2 font-medium">
616 <CheckCircle2 size={16} className="text-accent" />
617 {connection.name} is connected.
618 </p>
619 {connection.provider === "sentry" && url && (
620 <ol className="mt-3 list-decimal space-y-2 pl-5 text-sm text-muted">
621 <li>
622 In Sentry, open Settings → Developer Settings → Custom Integrations, and create an internal
623 integration.
624 </li>
625 <li>
626 Set its webhook URL to <CopyLine text={url} />
627 </li>
628 <li>Turn on Alert Rule Action, and under Webhooks, tick issue.</li>
629 <li>Give it Issue &amp; Event read and write, save, and paste its client secret below, on the connection.</li>
630 </ol>
631 )}
632 {(connection.provider === "datadog" || connection.provider === "webhook") && url && signingSecret && (
633 <div className="mt-3 space-y-3 text-sm text-muted">
634 <p>This secret is shown once. Copy it now.</p>
635 <CopyLine text={signingSecret} />
636 {connection.provider === "datadog" ? (
637 <>
638 <p>
639 In Datadog, open Integrations → Webhooks and add one named <code>g1t</code> with this URL:
640 </p>
641 <CopyLine text={url} />
642 <p>Custom headers:</p>
643 <CopyLine text={`{"Authorization": "Bearer ${signingSecret}"}`} />
644 <p>Payload:</p>
645 <CopyLine
646 text={`{"id": "$ALERT_ID", "title": "$EVENT_TITLE", "body": "$EVENT_MSG", "url": "$LINK", "status": "$ALERT_TRANSITION", "priority": "$PRIORITY"}`}
647 />
648 <p>
649 Then mention <code>@webhook-g1t</code> in any monitor's message.
650 </p>
651 </>
652 ) : (
653 <>
654 <p>Send JSON with at least a title to:</p>
655 <CopyLine text={url} />
656 <p>
657 with <code>Authorization: Bearer &lt;secret&gt;</code>, or an HMAC-SHA256 of the body in{" "}
658 <code>X-G1t-Signature</code>. An <code>id</code> keeps repeats on one issue.
659 </p>
660 </>
661 )}
662 </div>
663 )}
664 {connection.kind === "tracker" && (
665 <p className="mt-2 text-sm text-muted">
666 Agents now read tickets that work mentions, and you can import one from any repository's new
667 issue page. Use Test to check the token.
668 </p>
669 )}
670 {connection.kind === "models" && (
671 <p className="mt-2 text-sm text-muted">
672 {connection.lastError
673 ? `It was saved, but the check failed: ${connection.lastError}`
674 : connection.models.length > 0
675 ? `It offers ${connection.models.length} models. Choose which work goes to it under “Which model does which work”.`
676 : "Choose which work goes to it under “Which model does which work”."}
677 </p>
678 )}
679 </div>
680 );
681}