# Conflicts: # apps/cloud/src/api/slack.ts # apps/cloud/src/services/slack.ts # apps/local/src/web/shell.tsx # packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx # packages/plugins/graphql/src/react/AddGraphqlSource.tsx # packages/plugins/mcp/src/react/AddMcpSource.tsx # packages/plugins/openapi/src/react/AddOpenApiSource.tsx # packages/react/src/api/optimistic.tsx # packages/react/src/pages/source-detail.tsx
Unified · Split
| @@ -11,29 +11,6 @@ import { · 10 unmodified lines | ||
| 11 | 11 | |
| 12 | 12 | const isValidEmail = (s: string): boolean => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s); |
| 13 | 13 | |
| 14 | −const verifyTurnstile = (token: string, remoteIp: string | null) => | |
| 15 | − Effect.tryPromise({ | |
| 16 | − try: async () => { | |
| 17 | − const secret = env.TURNSTILE_SECRET_KEY; | |
| 18 | − if (!secret) { | |
| 19 | − // Turnstile is unconfigured — fail closed in prod, but allow dev to | |
| 20 | − // boot without it. We treat empty secret as "skip" so the form works | |
| 21 | − // before secrets are wired; remove this branch once you've set the | |
| 22 | − // secret in every environment. | |
| 23 | − return { success: true, skipped: true }; | |
| 24 | − } | |
| 25 | − const form = new URLSearchParams({ secret, response: token }); | |
| 26 | − if (remoteIp) form.set("remoteip", remoteIp); | |
| 27 | − const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { | |
| 28 | − method: "POST", | |
| 29 | − body: form, | |
| 30 | − }); | |
| 31 | − const json = (await res.json()) as { success: boolean; "error-codes"?: string[] }; | |
| 32 | − return { success: json.success, errorCodes: json["error-codes"] ?? [] }; | |
| 33 | − }, | |
| 34 | − catch: (cause) => ({ success: false as const, fetchError: cause }), | |
| 35 | − }); | |
| 36 | − | |
| 37 | 14 | const handler = Effect.gen(function* () { |
| 38 | 15 | const request = yield* HttpServerRequest.HttpServerRequest; |
| 39 | 16 | |
| @@ -55,20 +32,14 @@ const handler = Effect.gen(function* () { · 15 unmodified lines | ||
| 55 | 32 | }), |
| 56 | 33 | )) as { |
| 57 | 34 | email?: unknown; |
| 58 | − name?: unknown; | |
| 59 | − note?: unknown; | |
| 60 | 35 | organization?: unknown; |
| 61 | − turnstileToken?: unknown; | |
| 62 | 36 | }; |
| 63 | 37 | |
| 64 | 38 | const trimmed = (v: unknown, max: number): string | undefined => |
| 65 | 39 | typeof v === "string" && v.trim().length > 0 ? v.trim().slice(0, max) : undefined; |
| 66 | 40 | |
| 67 | 41 | const email = typeof body.email === "string" ? body.email.trim() : ""; |
| 68 | − const name = trimmed(body.name, 200); | |
| 69 | − const note = trimmed(body.note, 2000); | |
| 70 | 42 | const organization = trimmed(body.organization, 200); |
| 71 | − const turnstileToken = typeof body.turnstileToken === "string" ? body.turnstileToken : ""; | |
| 72 | 43 | |
| 73 | 44 | if (!isValidEmail(email)) { |
| 74 | 45 | return yield* new HttpResponseError({ |
| @@ -78,28 +49,8 @@ const handler = Effect.gen(function* () { · 3 unmodified lines | ||
| 78 | 49 | }); |
| 79 | 50 | } |
| 80 | 51 | |
| 81 | − if (!turnstileToken) { | |
| 82 | − return yield* new HttpResponseError({ | |
| 83 | − status: 400, | |
| 84 | − code: "captcha_required", | |
| 85 | − message: "Captcha verification is required.", | |
| 86 | − }); | |
| 87 | − } | |
| 88 | − | |
| 89 | − const remoteIp = request.headers["cf-connecting-ip"] ?? null; | |
| 90 | − const verification = yield* verifyTurnstile(turnstileToken, remoteIp); | |
| 91 | − if (!verification.success) { | |
| 92 | − console.error("[slack] turnstile verification failed:", verification); | |
| 93 | − return yield* new HttpResponseError({ | |
| 94 | − status: 403, | |
| 95 | − code: "captcha_failed", | |
| 96 | − message: "Captcha verification failed. Please try again.", | |
| 97 | − }); | |
| 98 | − } | |
| 99 | − | |
| 100 | − // Global daily channel-creation cap — bounds the worst case if Turnstile is | |
| 101 | − // bypassed somehow. Per-IP gating belongs at the edge (Cloudflare Rules); | |
| 102 | − // this binding is a single shared bucket keyed at "global". | |
| 52 | + // Global rate limit — single shared bucket keyed at "global". Per-IP gating | |
| 53 | + // belongs at the edge (Cloudflare Rules). | |
| 103 | 54 | const limit = yield* Effect.tryPromise({ |
| 104 | 55 | try: () => env.SLACK_INVITE_LIMITER.limit({ key: "global" }), |
| 105 | 56 | catch: (cause) => ({ success: false as const, fetchError: cause }), |
| @@ -114,7 +65,7 @@ const handler = Effect.gen(function* () { · 8 unmodified lines | ||
| 114 | 65 | } |
| 115 | 66 | |
| 116 | 67 | const slack = yield* SlackService; |
| 117 | − const { invite } = yield* slack.createConnectInvite({ email, name, note, organization }).pipe( | |
| 68 | + const { invite } = yield* slack.createConnectInvite({ email, organization }).pipe( | |
| 118 | 69 | Effect.tapError((err) => |
| 119 | 70 | Effect.sync(() => { |
| 120 | 71 | console.error(`[slack] ${err.method} failed:`, err.error); |
| @@ -25,11 +25,6 @@ declare global { · 24 unmodified lines | ||
| 25 | 25 | |
| 26 | 26 | // Contact / Slack Connect |
| 27 | 27 | SLACK_BOT_TOKEN?: string; |
| 28 | − // Turnstile (Cloudflare CAPTCHA) — used to gate the public Slack-contact | |
| 29 | − // endpoint. Sitekey is public and ships in `vars`; secret is set via | |
| 30 | − // `wrangler secret put TURNSTILE_SECRET_KEY`. | |
| 31 | − TURNSTILE_SECRET_KEY?: string; | |
| 32 | − VITE_PUBLIC_TURNSTILE_SITEKEY?: string; | |
| 33 | 28 | // Cloudflare ratelimit binding declared in wrangler.jsonc — caps total |
| 34 | 29 | // Slack-contact channel creations across all callers. |
| 35 | 30 | SLACK_INVITE_LIMITER: { limit: (input: { key: string }) => Promise<{ success: boolean }> }; |
| @@ -1,4 +1,4 @@ | ||
| 1 | −import { useEffect, useRef, useState } from "react"; | |
| 1 | +import { useEffect, useState } from "react"; | |
| 2 | 2 | import { createFileRoute, Link } from "@tanstack/react-router"; |
| 3 | 3 | import { useAuth } from "../web/auth"; |
| 4 | 4 | import { useCustomer, useListPlans } from "autumn-js/react"; |
| @@ -16,7 +16,6 @@ import { · 11 unmodified lines | ||
| 16 | 16 | } from "@executor-js/react/components/dialog"; |
| 17 | 17 | import { Input } from "@executor-js/react/components/input"; |
| 18 | 18 | import { Label } from "@executor-js/react/components/label"; |
| 19 | −import { Textarea } from "@executor-js/react/components/textarea"; | |
| 20 | 19 | |
| 21 | 20 | type Plan = NonNullable<ReturnType<typeof useListPlans>["data"]>[number]; |
| 22 | 21 | |
| @@ -310,40 +309,27 @@ function SlackContactCta() { · 287 unmodified lines | ||
| 310 | 309 | const auth = useAuth(); |
| 311 | 310 | const signedIn = auth.status === "authenticated" ? auth : null; |
| 312 | 311 | const prefillEmail = signedIn?.user.email ?? ""; |
| 313 | − const prefillName = signedIn?.user.name ?? ""; | |
| 314 | 312 | const orgName = signedIn?.organization?.name ?? ""; |
| 315 | 313 | |
| 316 | − const turnstileSiteKey = import.meta.env.VITE_PUBLIC_TURNSTILE_SITEKEY ?? ""; | |
| 317 | 314 | const [open, setOpen] = useState(false); |
| 318 | 315 | const [email, setEmail] = useState(prefillEmail); |
| 319 | − const [name, setName] = useState(prefillName); | |
| 320 | − const [note, setNote] = useState(""); | |
| 321 | 316 | const [submitting, setSubmitting] = useState(false); |
| 322 | 317 | const [error, setError] = useState<string | null>(null); |
| 323 | 318 | const [inviteUrl, setInviteUrl] = useState<string | null>(null); |
| 324 | − const [turnstileToken, setTurnstileToken] = useState<string | null>(null); | |
| 325 | 319 | |
| 326 | 320 | // Hydrate prefill once auth resolves (it starts as `loading` on first render). |
| 327 | 321 | useEffect(() => { |
| 328 | 322 | if (prefillEmail && !email) setEmail(prefillEmail); |
| 329 | − if (prefillName && !name) setName(prefillName); | |
| 330 | − }, [prefillEmail, prefillName]); // eslint-disable-line react-hooks/exhaustive-deps | |
| 323 | + }, [prefillEmail]); // eslint-disable-line react-hooks/exhaustive-deps | |
| 331 | 324 | |
| 332 | 325 | const reset = () => { |
| 333 | 326 | setEmail(prefillEmail); |
| 334 | − setName(prefillName); | |
| 335 | − setNote(""); | |
| 336 | 327 | setError(null); |
| 337 | 328 | setInviteUrl(null); |
| 338 | − setTurnstileToken(null); | |
| 339 | 329 | }; |
| 340 | 330 | |
| 341 | 331 | const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => { |
| 342 | 332 | e.preventDefault(); |
| 343 | − if (!turnstileToken) { | |
| 344 | − setError("Please complete the captcha first."); | |
| 345 | − return; | |
| 346 | − } | |
| 347 | 333 | setSubmitting(true); |
| 348 | 334 | setError(null); |
| 349 | 335 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: browser fetch submit path maps network failures to public UI copy |
| @@ -353,10 +339,7 @@ function SlackContactCta() { · 3 unmodified lines | ||
| 353 | 339 | headers: { "content-type": "application/json" }, |
| 354 | 340 | body: JSON.stringify({ |
| 355 | 341 | email, |
| 356 | − name: name || undefined, | |
| 357 | − note: note || undefined, | |
| 358 | 342 | organization: orgName || undefined, |
| 359 | − turnstileToken, | |
| 360 | 343 | }), |
| 361 | 344 | }); |
| 362 | 345 | const data = (await res.json().then( |
| @@ -372,9 +355,6 @@ function SlackContactCta() { · 9 unmodified lines | ||
| 372 | 355 | setError("Network error. Please try again."); |
| 373 | 356 | } finally { |
| 374 | 357 | setSubmitting(false); |
| 375 | − // Turnstile tokens are single-use server-side — clear so the widget | |
| 376 | − // re-issues a fresh one on retry. | |
| 377 | − setTurnstileToken(null); | |
| 378 | 358 | } |
| 379 | 359 | }; |
| 380 | 360 | |
| @@ -449,29 +429,6 @@ function SlackContactCta() { · 68 unmodified lines | ||
| 449 | 429 | placeholder="you@company.com" |
| 450 | 430 | /> |
| 451 | 431 | </div> |
| 452 | − <div className="grid gap-2"> | |
| 453 | − <Label htmlFor="slack-contact-name">Name (optional)</Label> | |
| 454 | − <Input | |
| 455 | − id="slack-contact-name" | |
| 456 | − autoComplete="name" | |
| 457 | − value={name} | |
| 458 | − onChange={(e) => setName(e.currentTarget.value)} | |
| 459 | − placeholder="Ada Lovelace" | |
| 460 | − /> | |
| 461 | − </div> | |
| 462 | − <div className="grid gap-2"> | |
| 463 | − <Label htmlFor="slack-contact-note">What's on your mind? (optional)</Label> | |
| 464 | − <Textarea | |
| 465 | − id="slack-contact-note" | |
| 466 | − rows={3} | |
| 467 | − value={note} | |
| 468 | − onChange={(e) => setNote(e.currentTarget.value)} | |
| 469 | − placeholder="Pricing question, integration help, anything…" | |
| 470 | − /> | |
| 471 | − </div> | |
| 472 | − {turnstileSiteKey && ( | |
| 473 | − <TurnstileWidget siteKey={turnstileSiteKey} onToken={setTurnstileToken} /> | |
| 474 | − )} | |
| 475 | 432 | {error && <p className="text-sm text-destructive">{error}</p>} |
| 476 | 433 | </div> |
| 477 | 434 | <DialogFooter> |
| @@ -480,10 +437,7 @@ function SlackContactCta() { · 2 unmodified lines | ||
| 480 | 437 | Cancel |
| 481 | 438 | </Button> |
| 482 | 439 | </DialogClose> |
| 483 | − <Button | |
| 484 | − type="submit" | |
| 485 | − disabled={submitting || !email || (!!turnstileSiteKey && !turnstileToken)} | |
| 486 | − > | |
| 440 | + <Button type="submit" disabled={submitting || !email}> | |
| 487 | 441 | {submitting ? "Sending…" : "Send invite"} |
| 488 | 442 | </Button> |
| 489 | 443 | </DialogFooter> |
| @@ -515,86 +469,6 @@ function MailIcon({ className }: { className?: string }) { · 25 unmodified lines | ||
| 515 | 469 | ); |
| 516 | 470 | } |
| 517 | 471 | |
| 518 | −type TurnstileApi = { | |
| 519 | − render: ( | |
| 520 | − el: HTMLElement, | |
| 521 | − opts: { | |
| 522 | − sitekey: string; | |
| 523 | − callback?: (token: string) => void; | |
| 524 | − "error-callback"?: () => void; | |
| 525 | − "expired-callback"?: () => void; | |
| 526 | − theme?: "light" | "dark" | "auto"; | |
| 527 | − }, | |
| 528 | − ) => string; | |
| 529 | − remove: (id: string) => void; | |
| 530 | −}; | |
| 531 | − | |
| 532 | −declare global { | |
| 533 | − interface Window { | |
| 534 | − turnstile?: TurnstileApi; | |
| 535 | − } | |
| 536 | −} | |
| 537 | − | |
| 538 | −function TurnstileWidget({ | |
| 539 | − siteKey, | |
| 540 | − onToken, | |
| 541 | −}: { | |
| 542 | − siteKey: string; | |
| 543 | − onToken: (token: string | null) => void; | |
| 544 | −}) { | |
| 545 | − const containerRef = useRef<HTMLDivElement>(null); | |
| 546 | − const widgetIdRef = useRef<string | null>(null); | |
| 547 | − const onTokenRef = useRef(onToken); | |
| 548 | − onTokenRef.current = onToken; | |
| 549 | − | |
| 550 | − useEffect(() => { | |
| 551 | − let cancelled = false; | |
| 552 | − const render = () => { | |
| 553 | − if (cancelled || !containerRef.current || !window.turnstile) return; | |
| 554 | − if (widgetIdRef.current) { | |
| 555 | − window.turnstile.remove(widgetIdRef.current); | |
| 556 | − widgetIdRef.current = null; | |
| 557 | − } | |
| 558 | − widgetIdRef.current = window.turnstile.render(containerRef.current, { | |
| 559 | − sitekey: siteKey, | |
| 560 | − callback: (token) => onTokenRef.current(token), | |
| 561 | − "error-callback": () => onTokenRef.current(null), | |
| 562 | − "expired-callback": () => onTokenRef.current(null), | |
| 563 | − }); | |
| 564 | − }; | |
| 565 | − | |
| 566 | − if (window.turnstile) { | |
| 567 | − render(); | |
| 568 | − } else { | |
| 569 | − const existing = document.querySelector<HTMLScriptElement>("script[data-turnstile]"); | |
| 570 | − if (existing) { | |
| 571 | − existing.addEventListener("load", render); | |
| 572 | − return () => { | |
| 573 | − cancelled = true; | |
| 574 | − existing.removeEventListener("load", render); | |
| 575 | − }; | |
| 576 | − } | |
| 577 | − const script = document.createElement("script"); | |
| 578 | − script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js"; | |
| 579 | − script.async = true; | |
| 580 | − script.defer = true; | |
| 581 | − script.dataset.turnstile = "1"; | |
| 582 | − script.addEventListener("load", render); | |
| 583 | − document.head.appendChild(script); | |
| 584 | − } | |
| 585 | − | |
| 586 | − return () => { | |
| 587 | − cancelled = true; | |
| 588 | − if (widgetIdRef.current && window.turnstile) { | |
| 589 | − window.turnstile.remove(widgetIdRef.current); | |
| 590 | − widgetIdRef.current = null; | |
| 591 | − } | |
| 592 | − }; | |
| 593 | − }, [siteKey]); | |
| 594 | − | |
| 595 | − return <div ref={containerRef} className="flex justify-center" />; | |
| 596 | −} | |
| 597 | − | |
| 598 | 472 | function SlackMark({ className }: { className?: string }) { |
| 599 | 473 | return ( |
| 600 | 474 | <svg viewBox="0 0 24 24" fill="none" className={className} aria-hidden> |
| @@ -5,18 +5,17 @@ · 4 unmodified lines | ||
| 5 | 5 | // --------------------------------------------------------------------------- |
| 6 | 6 | |
| 7 | 7 | import { env } from "cloudflare:workers"; |
| 8 | −import { Context, Data, Effect, Layer, Schema } from "effect"; | |
| 8 | +import { Context, Data, Effect, Layer } from "effect"; | |
| 9 | 9 | |
| 10 | 10 | export class SlackError extends Data.TaggedError("SlackError")<{ |
| 11 | 11 | method: string; |
| 12 | 12 | error: string; |
| 13 | + cause?: unknown; | |
| 13 | 14 | }> {} |
| 14 | 15 | |
| 15 | 16 | export type ISlackService = Readonly<{ |
| 16 | 17 | createConnectInvite: (input: { |
| 17 | 18 | email: string; |
| 18 | − name?: string; | |
| 19 | − note?: string; | |
| 20 | 19 | organization?: string; |
| 21 | 20 | }) => Effect.Effect< |
| 22 | 21 | { |
| @@ -27,8 +26,8 @@ export type ISlackService = Readonly<{ · 4 unmodified lines | ||
| 27 | 26 | >; |
| 28 | 27 | }>; |
| 29 | 28 | |
| 30 | −const slugifyEmail = (email: string): string => | |
| 31 | ||
| 29 | +const slugify = (s: string): string => | |
| 30 | + s | |
| 32 | 31 | .toLowerCase() |
| 33 | 32 | .replace(/[^a-z0-9]+/g, "-") |
| 34 | 33 | .replace(/^-+|-+$/g, "") |
| @@ -40,95 +39,64 @@ const randomSuffix = (): string => { · 5 unmodified lines | ||
| 40 | 39 | return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); |
| 41 | 40 | }; |
| 42 | 41 | |
| 43 | −const SlackErrorResponse = Schema.Struct({ | |
| 44 | − ok: Schema.Literal(false), | |
| 45 | − error: Schema.optional(Schema.String), | |
| 46 | −}); | |
| 47 | − | |
| 48 | −const SlackChannel = Schema.Struct({ id: Schema.String, name: Schema.String }); | |
| 49 | − | |
| 50 | −const SlackPostMessageResponse = Schema.Struct({ ok: Schema.Literal(true) }); | |
| 51 | − | |
| 52 | −const SlackCreateChannelResponse = Schema.Struct({ | |
| 53 | − ok: Schema.Literal(true), | |
| 54 | − channel: SlackChannel, | |
| 55 | −}); | |
| 56 | − | |
| 57 | −const SlackSharedInviteResponse = Schema.Struct({ | |
| 58 | − ok: Schema.Literal(true), | |
| 59 | − invite_id: Schema.String, | |
| 60 | − url: Schema.String, | |
| 61 | −}); | |
| 42 | +type SlackResponse = { ok: boolean; error?: string } & Record<string, unknown>; | |
| 62 | 43 | |
| 63 | 44 | const make = Effect.sync(() => { |
| 64 | 45 | const token = env.SLACK_BOT_TOKEN; |
| 65 | 46 | |
| 66 | 47 | if (!token) { |
| 67 | 48 | const notConfigured = (method: string) => |
| 68 | − Effect.fail(new SlackError({ method, error: "SLACK_BOT_TOKEN is not configured" })); | |
| 49 | + Effect.fail( | |
| 50 | + new SlackError({ method, error: "SLACK_BOT_TOKEN is not configured" }), | |
| 51 | + ); | |
| 69 | 52 | return { |
| 70 | 53 | createConnectInvite: () => notConfigured("createConnectInvite"), |
| 71 | 54 | } satisfies ISlackService; |
| 72 | 55 | } |
| 73 | 56 | |
| 74 | − const call = <A extends { ok: true }>( | |
| 75 | − method: string, | |
| 76 | − body: Record<string, unknown>, | |
| 77 | − successSchema: Schema.Decoder<A>, | |
| 78 | − ) => | |
| 79 | − Effect.gen(function* () { | |
| 80 | − const json = yield* Effect.tryPromise({ | |
| 81 | − try: async (): Promise<unknown> => { | |
| 82 | − const res = await fetch(`https://slack.com/api/${method}`, { | |
| 83 | − method: "POST", | |
| 84 | − headers: { | |
| 85 | − "content-type": "application/json; charset=utf-8", | |
| 86 | − authorization: `Bearer ${token}`, | |
| 87 | − }, | |
| 88 | − body: JSON.stringify(body), | |
| 89 | − }); | |
| 90 | − return res.json(); | |
| 91 | − }, | |
| 92 | − catch: () => | |
| 93 | − new SlackError({ | |
| 94 | − method, | |
| 95 | − error: "Failed to read Slack API response", | |
| 96 | − }), | |
| 97 | − }); | |
| 98 | − | |
| 99 | − const response = yield* Schema.decodeUnknownEffect( | |
| 100 | − Schema.Union([successSchema, SlackErrorResponse]), | |
| 101 | − )(json).pipe( | |
| 102 | − Effect.mapError( | |
| 103 | − () => | |
| 104 | − new SlackError({ | |
| 105 | − method, | |
| 106 | − error: "Unexpected Slack API response", | |
| 107 | − }), | |
| 108 | − ), | |
| 109 | − ); | |
| 110 | − | |
| 111 | − if (!response.ok) { | |
| 112 | − return yield* new SlackError({ | |
| 113 | − method, | |
| 114 | − error: response.error ?? "unknown_slack_error", | |
| 57 | + const call = <A extends SlackResponse>(method: string, body: Record<string, unknown>) => | |
| 58 | + Effect.tryPromise({ | |
| 59 | + try: async (): Promise<A> => { | |
| 60 | + const res = await fetch(`https://slack.com/api/${method}`, { | |
| 61 | + method: "POST", | |
| 62 | + headers: { | |
| 63 | + "content-type": "application/json; charset=utf-8", | |
| 64 | + authorization: `Bearer ${token}`, | |
| 65 | + }, | |
| 66 | + body: JSON.stringify(body), | |
| 115 | 67 | }); |
| 116 | − } | |
| 117 | − | |
| 118 | − return response; | |
| 119 | − }).pipe(Effect.withSpan(`slack.${method}`)); | |
| 68 | + const json = (await res.json()) as A; | |
| 69 | + return json; | |
| 70 | + }, | |
| 71 | + catch: (cause) => | |
| 72 | + new SlackError({ | |
| 73 | + method, | |
| 74 | + error: "Slack request failed", | |
| 75 | + cause, | |
| 76 | + }), | |
| 77 | + }).pipe( | |
| 78 | + Effect.flatMap((json) => | |
| 79 | + json.ok | |
| 80 | + ? Effect.succeed(json) | |
| 81 | + : Effect.fail(new SlackError({ method, error: json.error ?? "unknown_slack_error" })), | |
| 82 | + ), | |
| 83 | + Effect.withSpan(`slack.${method}`), | |
| 84 | + ); | |
| 120 | 85 | |
| 121 | 86 | const createConnectInvite: ISlackService["createConnectInvite"] = ({ |
| 122 | 87 | email, |
| 123 | − name, | |
| 124 | − note, | |
| 125 | 88 | organization, |
| 126 | 89 | }) => |
| 127 | 90 | Effect.gen(function* () { |
| 128 | − // Slack channel names: lowercase, no spaces, max 80 chars, unique per workspace. | |
| 129 | − const baseName = `shared-${slugifyEmail(email)}`.slice(0, 80); | |
| 91 | + // Slack channel names: lowercase, no spaces, max 80 chars, unique per | |
| 92 | + // workspace. Use the org name when available; fall back to the email | |
| 93 | + // local-part for unauthenticated submissions. | |
| 94 | + const baseName = slugify(organization ?? email).slice(0, 80) || "contact"; | |
| 130 | 95 | const tryCreate = (n: string) => |
| 131 | − call("conversations.create", { name: n, is_private: false }, SlackCreateChannelResponse); | |
| 96 | + call<SlackResponse & { channel: { id: string; name: string } }>( | |
| 97 | + "conversations.create", | |
| 98 | + { name: n, is_private: false }, | |
| 99 | + ); | |
| 132 | 100 | |
| 133 | 101 | const created = yield* tryCreate(baseName).pipe( |
| 134 | 102 | Effect.catchTag("SlackError", (err) => |
| @@ -139,39 +107,27 @@ const make = Effect.sync(() => { · 4 unmodified lines | ||
| 139 | 107 | ); |
| 140 | 108 | const channel = created.channel; |
| 141 | 109 | |
| 142 | − const helloLines = [ | |
| 143 | − `:wave: New contact from pricing page: ${email}`, | |
| 144 | − name ? `Name: ${name}` : null, | |
| 145 | − organization ? `Org: ${organization}` : null, | |
| 146 | − note ? `Note: ${note}` : null, | |
| 147 | − ].filter(Boolean) as string[]; | |
| 148 | − | |
| 149 | − yield* call( | |
| 150 | − "chat.postMessage", | |
| 151 | − { | |
| 152 | − channel: channel.id, | |
| 153 | − text: helloLines.join("\n"), | |
| 154 | − }, | |
| 155 | − SlackPostMessageResponse, | |
| 156 | − ); | |
| 157 | − | |
| 158 | − const invite = yield* call( | |
| 110 | + const invite = yield* call<SlackResponse & { invite_id: string; url: string }>( | |
| 159 | 111 | "conversations.inviteShared", |
| 160 | 112 | { |
| 161 | 113 | channel: channel.id, |
| 162 | 114 | emails: [email], |
| 163 | − // `external_limited: true` scopes the guest to just this channel, | |
| 164 | − // which is what we want for a focused 1:1 support conversation. | |
| 165 | − external_limited: true, | |
| 166 | 115 | }, |
| 167 | − SlackSharedInviteResponse, | |
| 116 | + ); | |
| 117 | + | |
| 118 | + // Best-effort: leave the channel so the bot doesn't sit in every | |
| 119 | + // contact channel forever. Don't fail the request if leave errors. | |
| 120 | + yield* call<SlackResponse>("conversations.leave", { channel: channel.id }).pipe( | |
| 121 | + Effect.ignore, | |
| 168 | 122 | ); |
| 169 | 123 | |
| 170 | 124 | return { |
| 171 | 125 | channel: { id: channel.id, name: channel.name }, |
| 172 | 126 | invite: { invite_id: invite.invite_id, url: invite.url }, |
| 173 | 127 | }; |
| 174 | − }).pipe(Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } })); | |
| 128 | + }).pipe( | |
| 129 | + Effect.withSpan("slack.createConnectInvite", { attributes: { "slack.email": email } }), | |
| 130 | + ); | |
| 175 | 131 | |
| 176 | 132 | return { createConnectInvite } satisfies ISlackService; |
| 177 | 133 | }); |
| @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; · 2 unmodified lines | ||
| 3 | 3 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | 5 | import * as Exit from "effect/Exit"; |
| 6 | −import { useSourcesWithPending } from "@executor-js/react/api/optimistic"; | |
| 6 | +import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; | |
| 7 | 7 | import { useScope } from "@executor-js/react/api/scope-context"; |
| 8 | 8 | import { Button } from "@executor-js/react/components/button"; |
| 9 | 9 | import { Skeleton } from "@executor-js/react/components/skeleton"; |
| @@ -60,7 +60,7 @@ function NavItem(props: { to: string; label: string; active: boolean; onNavigate · 50 unmodified lines | ||
| 60 | 60 | |
| 61 | 61 | function SourceList(props: { pathname: string; onNavigate?: () => void }) { |
| 62 | 62 | const scopeId = useScope(); |
| 63 | − const sources = useSourcesWithPending(scopeId); | |
| 63 | + const sources = useAtomValue(sourcesOptimisticAtom(scopeId)); | |
| 64 | 64 | |
| 65 | 65 | return AsyncResult.match(sources, { |
| 66 | 66 | onInitial: () => ( |
| @@ -54,15 +54,12 @@ · 53 unmodified lines | ||
| 54 | 54 | "vars": { |
| 55 | 55 | "VITE_PUBLIC_SITE_URL": "https://executor.sh", |
| 56 | 56 | "VITE_PUBLIC_POSTHOG_KEY": "phc_nNLrNMALpRsfrEkZovUkfMxYbcJvHnsJHeoSPavprgLL", |
| 57 | − // Replace with your Turnstile production sitekey before going live. The | |
| 58 | − // test key always passes — fine for local dev, useless in prod. | |
| 59 | − "VITE_PUBLIC_TURNSTILE_SITEKEY": "1x00000000000000000000AA", | |
| 60 | 57 | }, |
| 61 | 58 | // Cloudflare's `simple` ratelimit only supports 10s or 60s windows — it's |
| 62 | − // burst protection, not a true daily cap. With Turnstile gating the form, | |
| 63 | − // 5/min keeps the worst-case sustained throughput to ~7k channels/day, | |
| 64 | − // which is bounded enough as a backstop. For an actual daily ceiling, | |
| 65 | − // layer a Durable Object counter on top. | |
| 59 | + // burst protection, not a true daily cap. 5/min keeps the worst-case | |
| 60 | + // sustained throughput to ~7k channels/day, which is bounded enough as a | |
| 61 | + // backstop. For an actual daily ceiling, layer a Durable Object counter | |
| 62 | + // on top. | |
| 66 | 63 | "ratelimits": [ |
| 67 | 64 | { |
| 68 | 65 | "name": "SLACK_INVITE_LIMITER", |
| @@ -1,10 +1,10 @@ | ||
| 1 | 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; |
| 2 | 2 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 3 | −import { useAtomRefresh } from "@effect/atom-react"; | |
| 4 | −import { Data, Effect, Exit } from "effect"; | |
| 3 | +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; | |
| 4 | +import * as Effect from "effect/Effect"; | |
| 5 | +import * as Exit from "effect/Exit"; | |
| 5 | 6 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 6 | −import { sourcesAtom, toolsAtom } from "@executor-js/react/api/atoms"; | |
| 7 | −import { useSourcesWithPending } from "@executor-js/react/api/optimistic"; | |
| 7 | +import { sourcesAtom, sourcesOptimisticAtom, toolsAtom } from "@executor-js/react/api/atoms"; | |
| 8 | 8 | import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context"; |
| 9 | 9 | import { Button } from "@executor-js/react/components/button"; |
| 10 | 10 | import { SourceFavicon } from "@executor-js/react/components/source-favicon"; |
| @@ -30,10 +30,6 @@ type UpdateChannel = "latest" | "beta"; · 19 unmodified lines | ||
| 30 | 30 | |
| 31 | 31 | const EXECUTOR_DIST_TAGS_PATH = "/v1/app/npm/dist-tags"; |
| 32 | 32 | |
| 33 | −class LatestVersionCheckError extends Data.TaggedError("LatestVersionCheckError")<{ | |
| 34 | − readonly cause: unknown; | |
| 35 | −}> {} | |
| 36 | − | |
| 37 | 33 | type ParsedVersion = { |
| 38 | 34 | readonly major: number; |
| 39 | 35 | readonly minor: number; |
| @@ -100,22 +96,20 @@ function useLatestVersion(currentVersion: string) { · 60 unmodified lines | ||
| 100 | 96 | |
| 101 | 97 | useEffect(() => { |
| 102 | 98 | let cancelled = false; |
| 103 | − | |
| 104 | − const latestVersionEffect = Effect.tryPromise({ | |
| 105 | − try: async () => { | |
| 106 | − const res = await fetch(EXECUTOR_DIST_TAGS_PATH); | |
| 107 | − if (!res.ok) return {}; | |
| 108 | − return (await res.json()) as Partial<Record<UpdateChannel, string>>; | |
| 109 | − }, | |
| 110 | − catch: (cause) => new LatestVersionCheckError({ cause }), | |
| 111 | − }); | |
| 112 | − | |
| 113 | − void Effect.runPromiseExit(latestVersionEffect).then((exit) => { | |
| 99 | + void Effect.runPromiseExit( | |
| 100 | + Effect.tryPromise({ | |
| 101 | + try: async () => { | |
| 102 | + const res = await fetch(EXECUTOR_DIST_TAGS_PATH); | |
| 103 | + if (!res.ok) return null; | |
| 104 | + return (await res.json()) as Partial<Record<UpdateChannel, string>>; | |
| 105 | + }, | |
| 106 | + catch: (cause) => cause, | |
| 107 | + }), | |
| 108 | + ).then((exit) => { | |
| 114 | 109 | if (!cancelled && Exit.isSuccess(exit)) { |
| 115 | − setLatestVersion(exit.value[channel] ?? null); | |
| 110 | + setLatestVersion(exit.value?.[channel] ?? null); | |
| 116 | 111 | } |
| 117 | 112 | }); |
| 118 | − | |
| 119 | 113 | return () => { |
| 120 | 114 | cancelled = true; |
| 121 | 115 | }; |
| @@ -273,7 +267,7 @@ function PluginNav(props: { pathname: string; onNavigate?: () => void }) { · 151 unmodified lines | ||
| 273 | 267 | |
| 274 | 268 | function SourceList(props: { pathname: string; onNavigate?: () => void }) { |
| 275 | 269 | const scopeId = useScope(); |
| 276 | − const sources = useSourcesWithPending(scopeId); | |
| 270 | + const sources = useAtomValue(sourcesOptimisticAtom(scopeId)); | |
| 277 | 271 | |
| 278 | 272 | return AsyncResult.match(sources, { |
| 279 | 273 | onInitial: () => <div className="px-2.5 py-2 text-xs text-muted-foreground">Loading…</div>, |
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | −import { Exit, Option, Schema } from "effect"; | |
| 3 | +import * as Exit from "effect/Exit"; | |
| 4 | +import * as Option from "effect/Option"; | |
| 5 | +import * as Schema from "effect/Schema"; | |
| 4 | 6 | |
| 5 | −import { usePendingSources } from "@executor-js/react/api/optimistic"; | |
| 6 | 7 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 7 | 8 | import { useScope } from "@executor-js/react/api/scope-context"; |
| 8 | 9 | import type { SecretPickerSecret } from "@executor-js/react/plugins/secret-picker"; |
| @@ -45,10 +46,21 @@ import { FloatActions } from "@executor-js/react/components/float-actions"; · 36 unmodified lines | ||
| 45 | 46 | import { Input } from "@executor-js/react/components/input"; |
| 46 | 47 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; |
| 47 | 48 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 48 | −import { addGoogleDiscoverySource, probeGoogleDiscovery } from "./atoms"; | |
| 49 | +import { addGoogleDiscoverySourceOptimistic, probeGoogleDiscovery } from "./atoms"; | |
| 49 | 50 | import { GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, googleDiscoveryOAuthStrategy } from "./oauth"; |
| 50 | 51 | import { googleDiscoveryPresets, type GoogleDiscoveryPreset } from "../sdk/presets"; |
| 51 | 52 | |
| 53 | +const ErrorMessage = Schema.Struct({ message: Schema.String }); | |
| 54 | + | |
| 55 | +const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | |
| 56 | + Option.match( | |
| 57 | + Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)), | |
| 58 | + { | |
| 59 | + onNone: () => fallback, | |
| 60 | + onSome: ({ message }) => message, | |
| 61 | + }, | |
| 62 | + ); | |
| 63 | + | |
| 52 | 64 | type GoogleAuthKind = "none" | "oauth2"; |
| 53 | 65 | |
| 54 | 66 | // --------------------------------------------------------------------------- |
| @@ -96,50 +108,25 @@ type GoogleDiscoveryTemplate = GoogleDiscoveryPreset & { · 41 unmodified lines | ||
| 96 | 108 | }; |
| 97 | 109 | |
| 98 | 110 | const GOOGLE_G_ICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; |
| 99 | −const PublicErrorMessage = Schema.Struct({ | |
| 100 | − _tag: Schema.Literals([ | |
| 101 | − "GoogleDiscoveryParseError", | |
| 102 | − "GoogleDiscoveryOAuthError", | |
| 103 | − "GoogleDiscoverySourceError", | |
| 104 | − ]), | |
| 105 | − message: Schema.String, | |
| 106 | −}); | |
| 107 | − | |
| 108 | −const messageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => { | |
| 109 | − const error = Exit.findErrorOption(exit); | |
| 110 | − if (Option.isNone(error)) return fallback; | |
| 111 | − const errorMessage = Schema.decodeUnknownOption(PublicErrorMessage)(error.value); | |
| 112 | − return Option.match(errorMessage, { | |
| 113 | − onNone: () => fallback, | |
| 114 | − onSome: (value) => value.message, | |
| 115 | − }); | |
| 116 | −}; | |
| 117 | 111 | |
| 118 | −const parseUrlOption = (value: string): URL | null => { | |
| 119 | − // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor is the platform URL parser | |
| 112 | +function parseGoogleDiscoveryPreset(preset: GoogleDiscoveryPreset): GoogleDiscoveryTemplate { | |
| 113 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes user-provided preset URLs | |
| 120 | 114 | try { |
| 121 | − return new URL(value); | |
| 115 | + const url = new URL(preset.url); | |
| 116 | + const parts = url.pathname.split("/").filter(Boolean); | |
| 117 | + const apisIndex = parts.indexOf("apis"); | |
| 118 | + const service = apisIndex >= 0 ? parts[apisIndex + 1] : undefined; | |
| 119 | + const version = | |
| 120 | + apisIndex >= 0 ? parts[apisIndex + 2] : (url.searchParams.get("version") ?? undefined); | |
| 121 | + return { | |
| 122 | + ...preset, | |
| 123 | + discoveryUrl: preset.url, | |
| 124 | + service: service ?? url.hostname.replace(/\.googleapis\.com$/, ""), | |
| 125 | + version: version ?? "", | |
| 126 | + }; | |
| 122 | 127 | } catch { |
| 123 | − return null; | |
| 124 | − } | |
| 125 | −}; | |
| 126 | − | |
| 127 | −function parseGoogleDiscoveryPreset(preset: GoogleDiscoveryPreset): GoogleDiscoveryTemplate { | |
| 128 | − const url = parseUrlOption(preset.url); | |
| 129 | − if (!url) { | |
| 130 | 128 | return { ...preset, discoveryUrl: preset.url, service: preset.id, version: "" }; |
| 131 | 129 | } |
| 132 | − const parts = url.pathname.split("/").filter(Boolean); | |
| 133 | − const apisIndex = parts.indexOf("apis"); | |
| 134 | − const service = apisIndex >= 0 ? parts[apisIndex + 1] : undefined; | |
| 135 | − const version = | |
| 136 | − apisIndex >= 0 ? parts[apisIndex + 2] : (url.searchParams.get("version") ?? undefined); | |
| 137 | − return { | |
| 138 | − ...preset, | |
| 139 | − discoveryUrl: preset.url, | |
| 140 | − service: service ?? url.hostname.replace(/\.googleapis\.com$/, ""), | |
| 141 | − version: version ?? "", | |
| 142 | − }; | |
| 143 | 130 | } |
| 144 | 131 | |
| 145 | 132 | const GOOGLE_DISCOVERY_TEMPLATES = googleDiscoveryPresets.map(parseGoogleDiscoveryPreset); |
| @@ -230,8 +217,9 @@ export default function AddGoogleDiscoverySource(props: { · 84 unmodified lines | ||
| 230 | 217 | |
| 231 | 218 | const scopeId = useScope(); |
| 232 | 219 | const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promiseExit" }); |
| 233 | − const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promiseExit" }); | |
| 234 | − const { beginAdd } = usePendingSources(); | |
| 220 | + const doAdd = useAtomSet(addGoogleDiscoverySourceOptimistic(scopeId), { | |
| 221 | + mode: "promiseExit", | |
| 222 | + }); | |
| 235 | 223 | const secretList = useSecretPickerSecrets(); |
| 236 | 224 | const oauth = useOAuthPopupFlow({ |
| 237 | 225 | popupName: GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, |
| @@ -269,7 +257,7 @@ export default function AddGoogleDiscoverySource(props: { · 31 unmodified lines | ||
| 269 | 257 | if (Exit.isFailure(exit)) { |
| 270 | 258 | setProbe(null); |
| 271 | 259 | setLoadingProbe(false); |
| 272 | − setError(messageFromExit(exit, "Failed to inspect discovery document")); | |
| 260 | + setError(errorMessageFromExit(exit, "Failed to inspect discovery document")); | |
| 273 | 261 | return; |
| 274 | 262 | } |
| 275 | 263 | const result = exit.value; |
| @@ -354,11 +342,6 @@ export default function AddGoogleDiscoverySource(props: { · 78 unmodified lines | ||
| 354 | 342 | setError(null); |
| 355 | 343 | const displayName = identity.name.trim() || probe.name; |
| 356 | 344 | const namespace = resolvedNamespace; |
| 357 | − const placeholder = beginAdd({ | |
| 358 | − id: namespace, | |
| 359 | − name: displayName, | |
| 360 | − kind: "google-discovery", | |
| 361 | − }); | |
| 362 | 345 | const exit = await doAdd({ |
| 363 | 346 | params: { scopeId }, |
| 364 | 347 | payload: { |
| @@ -378,9 +361,8 @@ export default function AddGoogleDiscoverySource(props: { · 13 unmodified lines | ||
| 378 | 361 | }, |
| 379 | 362 | reactivityKeys: [...sourceWriteKeys], |
| 380 | 363 | }); |
| 381 | − placeholder.done(); | |
| 382 | 364 | if (Exit.isFailure(exit)) { |
| 383 | − setError(messageFromExit(exit, "Failed to add source")); | |
| 365 | + setError(errorMessageFromExit(exit, "Failed to add source")); | |
| 384 | 366 | setAdding(false); |
| 385 | 367 | return; |
| 386 | 368 | } |
| @@ -394,7 +376,6 @@ export default function AddGoogleDiscoverySource(props: { · 7 unmodified lines | ||
| 394 | 376 | oauthAuth, |
| 395 | 377 | props, |
| 396 | 378 | scopeId, |
| 397 | − beginAdd, | |
| 398 | 379 | resolvedNamespace, |
| 399 | 380 | ]); |
| 400 | 381 | |
| @@ -1,4 +1,7 @@ | ||
| 1 | 1 | import type { ScopeId } from "@executor-js/sdk/core"; |
| 2 | +import * as Atom from "effect/unstable/reactivity/Atom"; | |
| 3 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 4 | +import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; | |
| 2 | 5 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; |
| 3 | 6 | import { GoogleDiscoveryClient } from "./client"; |
| 4 | 7 | |
| @@ -25,6 +28,32 @@ export const addGoogleDiscoverySource = GoogleDiscoveryClient.mutation( · 20 unmodified lines | ||
| 25 | 28 | "googleDiscovery", |
| 26 | 29 | "addSource", |
| 27 | 30 | ); |
| 31 | +export const addGoogleDiscoverySourceOptimistic = Atom.family((scopeId: ScopeId) => | |
| 32 | + sourcesOptimisticAtom(scopeId).pipe( | |
| 33 | + Atom.optimisticFn({ | |
| 34 | + reducer: (current, arg) => | |
| 35 | + AsyncResult.map(current, (rows) => { | |
| 36 | + const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; | |
| 37 | + const source = { | |
| 38 | + id, | |
| 39 | + scopeId, | |
| 40 | + kind: "googleDiscovery", | |
| 41 | + pluginId: "google-discovery", | |
| 42 | + name: arg.payload.name, | |
| 43 | + url: arg.payload.discoveryUrl, | |
| 44 | + canRemove: false, | |
| 45 | + canRefresh: false, | |
| 46 | + canEdit: false, | |
| 47 | + runtime: false, | |
| 48 | + }; | |
| 49 | + return [source, ...rows.filter((row) => row.id !== id)].sort((a, b) => | |
| 50 | + a.name.localeCompare(b.name), | |
| 51 | + ); | |
| 52 | + }), | |
| 53 | + fn: addGoogleDiscoverySource, | |
| 54 | + }), | |
| 55 | + ), | |
| 56 | +); | |
| 28 | 57 | export const updateGoogleDiscoverySource = GoogleDiscoveryClient.mutation( |
| 29 | 58 | "googleDiscovery", |
| 30 | 59 | "updateSource", |
| @@ -1,10 +1,11 @@ | ||
| 1 | 1 | import { useCallback, useState } from "react"; |
| 2 | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | 3 | import * as Exit from "effect/Exit"; |
| 4 | +import * as Option from "effect/Option"; | |
| 5 | +import * as Schema from "effect/Schema"; | |
| 4 | 6 | |
| 5 | 7 | import { useScope } from "@executor-js/react/api/scope-context"; |
| 6 | 8 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 7 | −import { usePendingSources } from "@executor-js/react/api/optimistic"; | |
| 8 | 9 | import { |
| 9 | 10 | HttpCredentialsEditor, |
| 10 | 11 | httpCredentialsValid, |
| @@ -34,10 +35,21 @@ import { · 23 unmodified lines | ||
| 34 | 35 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 35 | 36 | import { Input } from "@executor-js/react/components/input"; |
| 36 | 37 | import { Spinner } from "@executor-js/react/components/spinner"; |
| 37 | −import { addGraphqlSource } from "./atoms"; | |
| 38 | +import { addGraphqlSourceOptimistic } from "./atoms"; | |
| 38 | 39 | import { initialGraphqlCredentials } from "./defaults"; |
| 39 | 40 | import type { HeaderValue } from "../sdk/types"; |
| 40 | 41 | |
| 42 | +const ErrorMessage = Schema.Struct({ message: Schema.String }); | |
| 43 | + | |
| 44 | +const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | |
| 45 | + Option.match( | |
| 46 | + Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)), | |
| 47 | + { | |
| 48 | + onNone: () => fallback, | |
| 49 | + onSome: ({ message }) => message, | |
| 50 | + }, | |
| 51 | + ); | |
| 52 | + | |
| 41 | 53 | type AuthMode = "none" | "oauth2"; |
| 42 | 54 | |
| 43 | 55 | export default function AddGraphqlSource(props: { |
| @@ -56,8 +68,7 @@ export default function AddGraphqlSource(props: { · 12 unmodified lines | ||
| 56 | 68 | const [tokens, setTokens] = useState<OAuthCompletionPayload | null>(null); |
| 57 | 69 | |
| 58 | 70 | const scopeId = useScope(); |
| 59 | − const doAdd = useAtomSet(addGraphqlSource, { mode: "promiseExit" }); | |
| 60 | − const { beginAdd } = usePendingSources(); | |
| 71 | + const doAdd = useAtomSet(addGraphqlSourceOptimistic(scopeId), { mode: "promiseExit" }); | |
| 61 | 72 | const secretList = useSecretPickerSecrets(); |
| 62 | 73 | const oauth = useOAuthPopupFlow({ |
| 63 | 74 | popupName: "graphql-oauth", |
| @@ -113,18 +124,12 @@ export default function AddGraphqlSource(props: { · 49 unmodified lines | ||
| 113 | 124 | const { headers: headerMap, queryParams } = serializeHttpCredentials(credentials); |
| 114 | 125 | |
| 115 | 126 | const { trimmedEndpoint, namespace, displayName } = sourceIdentity(); |
| 116 | − const placeholder = beginAdd({ | |
| 117 | − id: namespace, | |
| 118 | − name: displayName, | |
| 119 | − kind: "graphql", | |
| 120 | − url: trimmedEndpoint || undefined, | |
| 121 | − }); | |
| 122 | 127 | const exit = await doAdd({ |
| 123 | 128 | params: { scopeId }, |
| 124 | 129 | payload: { |
| 125 | 130 | endpoint: trimmedEndpoint, |
| 126 | − name: identity.name.trim() || undefined, | |
| 127 | − namespace: slugifyNamespace(identity.namespace) || undefined, | |
| 131 | + name: displayName, | |
| 132 | + namespace, | |
| 128 | 133 | ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}), |
| 129 | 134 | ...(Object.keys(queryParams).length > 0 |
| 130 | 135 | ? { queryParams: queryParams as Record<string, HeaderValue> } |
| @@ -140,14 +145,11 @@ export default function AddGraphqlSource(props: { · 9 unmodified lines | ||
| 140 | 145 | }, |
| 141 | 146 | reactivityKeys: sourceWriteKeys, |
| 142 | 147 | }); |
| 143 | − placeholder.done(); | |
| 144 | − | |
| 145 | 148 | if (Exit.isFailure(exit)) { |
| 146 | − setAddError("Failed to add source"); | |
| 149 | + setAddError(errorMessageFromExit(exit, "Failed to add source")); | |
| 147 | 150 | setAdding(false); |
| 148 | 151 | return; |
| 149 | 152 | } |
| 150 | − | |
| 151 | 153 | props.onComplete(); |
| 152 | 154 | }; |
| 153 | 155 | |
| @@ -1,4 +1,7 @@ | ||
| 1 | 1 | import type { ScopeId } from "@executor-js/sdk/core"; |
| 2 | +import * as Atom from "effect/unstable/reactivity/Atom"; | |
| 3 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 4 | +import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; | |
| 2 | 5 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; |
| 3 | 6 | import { GraphqlClient } from "./client"; |
| 4 | 7 | |
| @@ -19,4 +22,31 @@ export const graphqlSourceAtom = (scopeId: ScopeId, namespace: string) => · 14 unmodified lines | ||
| 19 | 22 | |
| 20 | 23 | export const addGraphqlSource = GraphqlClient.mutation("graphql", "addSource"); |
| 21 | 24 | |
| 25 | +export const addGraphqlSourceOptimistic = Atom.family((scopeId: ScopeId) => | |
| 26 | + sourcesOptimisticAtom(scopeId).pipe( | |
| 27 | + Atom.optimisticFn({ | |
| 28 | + reducer: (current, arg) => | |
| 29 | + AsyncResult.map(current, (rows) => { | |
| 30 | + const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; | |
| 31 | + const source = { | |
| 32 | + id, | |
| 33 | + scopeId, | |
| 34 | + kind: "graphql", | |
| 35 | + pluginId: "graphql", | |
| 36 | + name: arg.payload.name ?? id, | |
| 37 | + url: arg.payload.endpoint, | |
| 38 | + canRemove: false, | |
| 39 | + canRefresh: false, | |
| 40 | + canEdit: false, | |
| 41 | + runtime: false, | |
| 42 | + }; | |
| 43 | + return [source, ...rows.filter((row) => row.id !== id)].sort((a, b) => | |
| 44 | + a.name.localeCompare(b.name), | |
| 45 | + ); | |
| 46 | + }), | |
| 47 | + fn: addGraphqlSource, | |
| 48 | + }), | |
| 49 | + ), | |
| 50 | +); | |
| 51 | + | |
| 22 | 52 | export const updateGraphqlSource = GraphqlClient.mutation("graphql", "updateSource"); |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; |
| 2 | 2 | import { useAtomSet } from "@effect/atom-react"; |
| 3 | −import { Exit, Option, Schema } from "effect"; | |
| 3 | +import * as Exit from "effect/Exit"; | |
| 4 | +import * as Option from "effect/Option"; | |
| 5 | +import * as Schema from "effect/Schema"; | |
| 4 | 6 | |
| 5 | 7 | import { useScope } from "@executor-js/react/api/scope-context"; |
| 6 | 8 | import { Button } from "@executor-js/react/components/button"; |
| @@ -50,24 +52,19 @@ import { · 43 unmodified lines | ||
| 50 | 52 | |
| 51 | 53 | type RemoteAuthMode = "none" | "header" | "oauth2"; |
| 52 | 54 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 53 | −import { usePendingSources } from "@executor-js/react/api/optimistic"; | |
| 54 | −import { probeMcpEndpoint, addMcpSource } from "./atoms"; | |
| 55 | +import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; | |
| 55 | 56 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 56 | 57 | |
| 57 | −const PublicErrorMessage = Schema.Struct({ | |
| 58 | − _tag: Schema.Literals(["McpConnectionError", "McpToolDiscoveryError", "McpOAuthError"]), | |
| 59 | − message: Schema.String, | |
| 60 | −}); | |
| 61 | − | |
| 62 | −const messageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => { | |
| 63 | − const error = Exit.findErrorOption(exit); | |
| 64 | − if (Option.isNone(error)) return fallback; | |
| 65 | − const errorMessage = Schema.decodeUnknownOption(PublicErrorMessage)(error.value); | |
| 66 | − return Option.match(errorMessage, { | |
| 67 | − onNone: () => fallback, | |
| 68 | − onSome: (value) => value.message, | |
| 69 | − }); | |
| 70 | −}; | |
| 58 | +const ErrorMessage = Schema.Struct({ message: Schema.String }); | |
| 59 | + | |
| 60 | +const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => | |
| 61 | + Option.match( | |
| 62 | + Option.flatMap(Exit.findErrorOption(exit), Schema.decodeUnknownOption(ErrorMessage)), | |
| 63 | + { | |
| 64 | + onNone: () => fallback, | |
| 65 | + onSome: ({ message }) => message, | |
| 66 | + }, | |
| 67 | + ); | |
| 71 | 68 | |
| 72 | 69 | // --------------------------------------------------------------------------- |
| 73 | 70 | // Preset lookup |
| @@ -285,8 +282,7 @@ export default function AddMcpSource(props: { · 211 unmodified lines | ||
| 285 | 282 | |
| 286 | 283 | const scopeId = useScope(); |
| 287 | 284 | const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promiseExit" }); |
| 288 | − const doAdd = useAtomSet(addMcpSource, { mode: "promiseExit" }); | |
| 289 | − const { beginAdd } = usePendingSources(); | |
| 285 | + const doAdd = useAtomSet(addMcpSourceOptimistic(scopeId), { mode: "promiseExit" }); | |
| 290 | 286 | const secretList = useSecretPickerSecrets(); |
| 291 | 287 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ |
| 292 | 288 | popupName: "mcp-oauth", |
| @@ -361,7 +357,7 @@ export default function AddMcpSource(props: { · 68 unmodified lines | ||
| 361 | 357 | if (Exit.isFailure(exit)) { |
| 362 | 358 | dispatch({ |
| 363 | 359 | type: "probe-fail", |
| 364 | − error: messageFromExit(exit, "Failed to connect"), | |
| 360 | + error: errorMessageFromExit(exit, "Failed to connect"), | |
| 365 | 361 | }); |
| 366 | 362 | return; |
| 367 | 363 | } |
| @@ -482,13 +478,6 @@ export default function AddMcpSource(props: { · 114 unmodified lines | ||
| 482 | 478 | }; |
| 483 | 479 | const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name; |
| 484 | 480 | const slugNamespace = slugifyNamespace(remoteIdentity.namespace); |
| 485 | − const placeholderId = slugNamespace || `pending:${crypto.randomUUID()}`; | |
| 486 | − const placeholder = beginAdd({ | |
| 487 | − id: placeholderId, | |
| 488 | − name: displayName, | |
| 489 | − kind: "mcp", | |
| 490 | − url: state.url.trim(), | |
| 491 | − }); | |
| 492 | 481 | const exit = await doAdd({ |
| 493 | 482 | params: { scopeId }, |
| 494 | 483 | payload: { |
| @@ -504,11 +493,10 @@ export default function AddMcpSource(props: { · 9 unmodified lines | ||
| 504 | 493 | }, |
| 505 | 494 | reactivityKeys: sourceWriteKeys, |
| 506 | 495 | }); |
| 507 | − placeholder.done(); | |
| 508 | 496 | if (Exit.isFailure(exit)) { |
| 509 | 497 | dispatch({ |
| 510 | 498 | type: "add-fail", |
| 511 | − error: messageFromExit(exit, "Failed to add source"), | |
| 499 | + error: errorMessageFromExit(exit, "Failed to add source"), | |
| 512 | 500 | }); |
| 513 | 501 | return; |
| 514 | 502 | } |
| @@ -525,7 +513,6 @@ export default function AddMcpSource(props: { · 10 unmodified lines | ||
| 525 | 513 | doAdd, |
| 526 | 514 | props, |
| 527 | 515 | scopeId, |
| 528 | − beginAdd, | |
| 529 | 516 | ]); |
| 530 | 517 | |
| 531 | 518 | // ---- Stdio actions ---- |
| @@ -560,12 +547,6 @@ export default function AddMcpSource(props: { · 28 unmodified lines | ||
| 560 | 547 | setStdioError(null); |
| 561 | 548 | const displayName = stdioIdentity.name.trim() || cmd; |
| 562 | 549 | const slugNamespace = slugifyNamespace(stdioIdentity.namespace); |
| 563 | − const placeholderId = slugNamespace || `pending:${crypto.randomUUID()}`; | |
| 564 | − const placeholder = beginAdd({ | |
| 565 | − id: placeholderId, | |
| 566 | − name: displayName, | |
| 567 | − kind: "mcp", | |
| 568 | − }); | |
| 569 | 550 | const exit = await doAdd({ |
| 570 | 551 | params: { scopeId }, |
| 571 | 552 | payload: { |
| @@ -578,14 +559,13 @@ export default function AddMcpSource(props: { · 6 unmodified lines | ||
| 578 | 559 | }, |
| 579 | 560 | reactivityKeys: sourceWriteKeys, |
| 580 | 561 | }); |
| 581 | − placeholder.done(); | |
| 582 | 562 | if (Exit.isFailure(exit)) { |
| 583 | − setStdioError(messageFromExit(exit, "Failed to add source")); | |
| 563 | + setStdioError(errorMessageFromExit(exit, "Failed to add source")); | |
| 584 | 564 | setStdioAdding(false); |
| 585 | 565 | return; |
| 586 | 566 | } |
| 587 | 567 | props.onComplete(); |
| 588 | − }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props, beginAdd]); | |
| 568 | + }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props]); | |
| 589 | 569 | |
| 590 | 570 | // ---- Render ---- |
| 591 | 571 | |
| @@ -1,4 +1,7 @@ | ||
| 1 | 1 | import type { ScopeId } from "@executor-js/sdk/core"; |
| 2 | +import * as Atom from "effect/unstable/reactivity/Atom"; | |
| 3 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 4 | +import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; | |
| 2 | 5 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; |
| 3 | 6 | import { McpClient } from "./client"; |
| 4 | 7 | |
| @@ -19,6 +22,32 @@ export const mcpSourceAtom = (scopeId: ScopeId, namespace: string) => · 14 unmodified lines | ||
| 19 | 22 | |
| 20 | 23 | export const probeMcpEndpoint = McpClient.mutation("mcp", "probeEndpoint"); |
| 21 | 24 | export const addMcpSource = McpClient.mutation("mcp", "addSource"); |
| 25 | +export const addMcpSourceOptimistic = Atom.family((scopeId: ScopeId) => | |
| 26 | + sourcesOptimisticAtom(scopeId).pipe( | |
| 27 | + Atom.optimisticFn({ | |
| 28 | + reducer: (current, arg) => | |
| 29 | + AsyncResult.map(current, (rows) => { | |
| 30 | + const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; | |
| 31 | + const source = { | |
| 32 | + id, | |
| 33 | + scopeId, | |
| 34 | + kind: "mcp", | |
| 35 | + pluginId: "mcp", | |
| 36 | + name: arg.payload.name ?? id, | |
| 37 | + ...(arg.payload.transport === "remote" ? { url: arg.payload.endpoint } : {}), | |
| 38 | + canRemove: false, | |
| 39 | + canRefresh: false, | |
| 40 | + canEdit: false, | |
| 41 | + runtime: false, | |
| 42 | + }; | |
| 43 | + return [source, ...rows.filter((row) => row.id !== id)].sort((a, b) => | |
| 44 | + a.name.localeCompare(b.name), | |
| 45 | + ); | |
| 46 | + }), | |
| 47 | + fn: addMcpSource, | |
| 48 | + }), | |
| 49 | + ), | |
| 50 | +); | |
| 22 | 51 | export const removeMcpSource = McpClient.mutation("mcp", "removeSource"); |
| 23 | 52 | export const refreshMcpSource = McpClient.mutation("mcp", "refreshSource"); |
| 24 | 53 | export const updateMcpSource = McpClient.mutation("mcp", "updateSource"); |
| @@ -16,7 +16,6 @@ import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/rea · 15 unmodified lines | ||
| 16 | 16 | // state without a refresh. |
| 17 | 17 | const addSpecWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const; |
| 18 | 18 | const bindingWriteKeys = [...sourceWriteKeys, ...connectionWriteKeys] as const; |
| 19 | −import { usePendingSources } from "@executor-js/react/api/optimistic"; | |
| 20 | 19 | import { HeadersList } from "@executor-js/react/plugins/headers-list"; |
| 21 | 20 | import { |
| 22 | 21 | HttpCredentialsEditor, |
| @@ -67,7 +66,7 @@ import { SourceFavicon } from "@executor-js/react/components/source-favicon"; · 44 unmodified lines | ||
| 67 | 66 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; |
| 68 | 67 | import { Skeleton } from "@executor-js/react/components/skeleton"; |
| 69 | 68 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 70 | −import { addOpenApiSpec, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; | |
| 69 | +import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; | |
| 71 | 70 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; |
| 72 | 71 | import { |
| 73 | 72 | headerBindingSlot, |
| @@ -83,6 +82,9 @@ import { · 9 unmodified lines | ||
| 83 | 82 | type ServerVariable, |
| 84 | 83 | } from "../sdk/types"; |
| 85 | 84 | |
| 85 | +export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; | |
| 86 | +export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback"; | |
| 87 | + | |
| 86 | 88 | const ErrorMessage = Schema.Struct({ message: Schema.String }); |
| 87 | 89 | |
| 88 | 90 | const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => |
| @@ -94,9 +96,6 @@ const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: strin · 5 unmodified lines | ||
| 94 | 96 | }, |
| 95 | 97 | ); |
| 96 | 98 | |
| 97 | −export const OPENAPI_OAUTH_POPUP_NAME = "openapi-oauth"; | |
| 98 | −export const OPENAPI_OAUTH_CALLBACK_PATH = "/api/oauth/callback"; | |
| 99 | − | |
| 100 | 99 | const substituteUrlVariables = (url: string, values: Record<string, string>): string => { |
| 101 | 100 | let out = url; |
| 102 | 101 | for (const [name, value] of Object.entries(values)) { |
| @@ -122,15 +121,28 @@ export const openApiOAuthConnectionId = ( · 19 unmodified lines | ||
| 122 | 121 | */ |
| 123 | 122 | export function resolveOAuthUrl(url: string, baseUrl: string): string { |
| 124 | 123 | if (!url) return url; |
| 125 | − if (URL.canParse(url)) { | |
| 124 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs | |
| 125 | + try { | |
| 126 | + new URL(url); | |
| 126 | 127 | return url; |
| 128 | + } catch { | |
| 129 | + if (!baseUrl) return url; | |
| 130 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs | |
| 131 | + try { | |
| 132 | + return new URL(url, baseUrl).toString(); | |
| 133 | + } catch { | |
| 134 | + return url; | |
| 135 | + } | |
| 127 | 136 | } |
| 128 | − if (!baseUrl || !URL.canParse(url, baseUrl)) return url; | |
| 129 | − return new URL(url, baseUrl).toString(); | |
| 130 | 137 | } |
| 131 | 138 | |
| 132 | 139 | export function inferOAuthIssuerUrl(authorizationUrl: string): string | null { |
| 133 | − return URL.canParse(authorizationUrl) ? new URL(authorizationUrl).origin : null; | |
| 140 | + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs | |
| 141 | + try { | |
| 142 | + return new URL(authorizationUrl).origin; | |
| 143 | + } catch { | |
| 144 | + return null; | |
| 145 | + } | |
| 134 | 146 | } |
| 135 | 147 | |
| 136 | 148 | type StrategySelection = |
| @@ -246,10 +258,9 @@ export default function AddOpenApiSource(props: { · 109 unmodified lines | ||
| 246 | 258 | const scopeId = useScope(); |
| 247 | 259 | const userScope = useUserScope(); |
| 248 | 260 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); |
| 249 | − const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" }); | |
| 261 | + const doAdd = useAtomSet(addOpenApiSpecOptimistic(scopeId), { mode: "promiseExit" }); | |
| 250 | 262 | const doStartOAuth = useAtomSet(startOAuth, { mode: "promiseExit" }); |
| 251 | 263 | const doSetBinding = useAtomSet(setOpenApiSourceBinding, { mode: "promiseExit" }); |
| 252 | − const { beginAdd } = usePendingSources(); | |
| 253 | 264 | const secretList = useSecretPickerSecrets(); |
| 254 | 265 | const oauth = useOAuthPopupFlow<OAuthCompletionPayload>({ |
| 255 | 266 | popupName: OPENAPI_OAUTH_POPUP_NAME, |
| @@ -281,7 +292,7 @@ export default function AddOpenApiSource(props: { · 25 unmodified lines | ||
| 281 | 292 | selectedServerIndex >= 0 ? (servers[selectedServerIndex] ?? null) : null; |
| 282 | 293 | |
| 283 | 294 | const serverVariables: Record<string, ServerVariable> = selectedServer |
| 284 | − ? Option.getOrElse(selectedServer.variables, () => ({})) | |
| 295 | + ? Option.getOrElse(selectedServer.variables, () => ({}) as Record<string, ServerVariable>) | |
| 285 | 296 | : {}; |
| 286 | 297 | const serverVariableEntries: Array<[string, ServerVariable]> = Object.entries(serverVariables); |
| 287 | 298 | |
| @@ -293,7 +304,10 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines | ||
| 293 | 304 | // Helper used by analyze + server selection: build a default selection map |
| 294 | 305 | // from a server's variable defaults. |
| 295 | 306 | const defaultSelectionsFor = (server: ServerInfo): Record<string, string> => { |
| 296 | − const vars: Record<string, ServerVariable> = Option.getOrElse(server.variables, () => ({})); | |
| 307 | + const vars: Record<string, ServerVariable> = Option.getOrElse( | |
| 308 | + server.variables, | |
| 309 | + () => ({}) as Record<string, ServerVariable>, | |
| 310 | + ); | |
| 297 | 311 | const out: Record<string, string> = {}; |
| 298 | 312 | for (const [name, v] of Object.entries(vars)) out[name] = v.default; |
| 299 | 313 | return out; |
| @@ -384,33 +398,32 @@ export default function AddOpenApiSource(props: { · 84 unmodified lines | ||
| 384 | 398 | setAnalyzing(false); |
| 385 | 399 | return; |
| 386 | 400 | } |
| 387 | − | |
| 388 | 401 | const result = exit.value; |
| 389 | 402 | setPreview(result); |
| 390 | 403 | |
| 391 | − const firstServer = result.servers[0]; | |
| 392 | − if (firstServer) { | |
| 393 | − setSelectedServerIndex(0); | |
| 394 | − setVariableSelections(defaultSelectionsFor(firstServer)); | |
| 395 | − setCustomBaseUrl(""); | |
| 396 | − } else { | |
| 397 | − setSelectedServerIndex(-1); | |
| 398 | − setVariableSelections({}); | |
| 399 | − setCustomBaseUrl(""); | |
| 400 | − } | |
| 404 | + const firstServer = result.servers[0]; | |
| 405 | + if (firstServer) { | |
| 406 | + setSelectedServerIndex(0); | |
| 407 | + setVariableSelections(defaultSelectionsFor(firstServer)); | |
| 408 | + setCustomBaseUrl(""); | |
| 409 | + } else { | |
| 410 | + setSelectedServerIndex(-1); | |
| 411 | + setVariableSelections({}); | |
| 412 | + setCustomBaseUrl(""); | |
| 413 | + } | |
| 401 | 414 | |
| 402 | − const firstPreset = result.headerPresets[0]; | |
| 403 | − if (firstPreset) { | |
| 404 | − setStrategy({ kind: "header", presetIndex: 0 }); | |
| 405 | − setCustomHeaders(entriesFromSpecPreset(firstPreset)); | |
| 406 | − } else { | |
| 407 | − // No header presets — default to "custom" so the headers editor is | |
| 408 | − // visible immediately. Specs with no `security` block (e.g. Microsoft | |
| 409 | − // Graph) would otherwise leave the user staring at just the | |
| 410 | − // Authentication heading with no way to add headers. | |
| 411 | − setStrategy({ kind: "custom" }); | |
| 412 | − setCustomHeaders([]); | |
| 413 | − } | |
| 415 | + const firstPreset = result.headerPresets[0]; | |
| 416 | + if (firstPreset) { | |
| 417 | + setStrategy({ kind: "header", presetIndex: 0 }); | |
| 418 | + setCustomHeaders(entriesFromSpecPreset(firstPreset)); | |
| 419 | + } else { | |
| 420 | + // No header presets — default to "custom" so the headers editor is | |
| 421 | + // visible immediately. Specs with no `security` block (e.g. Microsoft | |
| 422 | + // Graph) would otherwise leave the user staring at just the | |
| 423 | + // Authentication heading with no way to add headers. | |
| 424 | + setStrategy({ kind: "custom" }); | |
| 425 | + setCustomHeaders([]); | |
| 426 | + } | |
| 414 | 427 | setAnalyzing(false); |
| 415 | 428 | }; |
| 416 | 429 | |
| @@ -477,59 +490,59 @@ export default function AddOpenApiSource(props: { · 60 unmodified lines | ||
| 477 | 490 | const tokenUrl = resolveOAuthUrl(selectedOAuth2Preset.tokenUrl, resolvedBaseUrl); |
| 478 | 491 | |
| 479 | 492 | if (selectedOAuth2Preset.flow === "clientCredentials") { |
| 480 | − // RFC 6749 §4.4: no user-interactive consent step. The client_secret | |
| 481 | − // is mandatory; the backend exchanges tokens inline and returns a | |
| 482 | − // completed OAuth2Auth we can attach to the source directly. | |
| 483 | − if (!oauth2ClientSecretSecretId) { | |
| 484 | − setOauth2Error("client_credentials requires a client secret"); | |
| 485 | − return; | |
| 486 | − } | |
| 487 | − setStartingOAuth(true); | |
| 488 | − const connectionId = openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow); | |
| 489 | − const startOAuthExit = await doStartOAuth({ | |
| 490 | − params: { scopeId }, | |
| 491 | − payload: { | |
| 492 | − endpoint: tokenUrl, | |
| 493 | − redirectUrl: tokenUrl, | |
| 494 | − connectionId, | |
| 495 | − tokenScope: scopeId, | |
| 496 | − strategy: { | |
| 497 | − kind: "client-credentials", | |
| 498 | − tokenEndpoint: tokenUrl, | |
| 493 | + // RFC 6749 §4.4: no user-interactive consent step. The client_secret | |
| 494 | + // is mandatory; the backend exchanges tokens inline and returns a | |
| 495 | + // completed OAuth2Auth we can attach to the source directly. | |
| 496 | + if (!oauth2ClientSecretSecretId) { | |
| 497 | + setOauth2Error("client_credentials requires a client secret"); | |
| 498 | + return; | |
| 499 | + } | |
| 500 | + setStartingOAuth(true); | |
| 501 | + const connectionId = openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow); | |
| 502 | + const exit = await doStartOAuth({ | |
| 503 | + params: { scopeId }, | |
| 504 | + payload: { | |
| 505 | + endpoint: tokenUrl, | |
| 506 | + redirectUrl: tokenUrl, | |
| 507 | + connectionId, | |
| 508 | + tokenScope: scopeId, | |
| 509 | + strategy: { | |
| 510 | + kind: "client-credentials", | |
| 511 | + tokenEndpoint: tokenUrl, | |
| 512 | + clientIdSecretId: oauth2ClientIdSecretId, | |
| 513 | + clientSecretSecretId: oauth2ClientSecretSecretId, | |
| 514 | + scopes: [...oauth2SelectedScopes], | |
| 515 | + }, | |
| 516 | + pluginId: "openapi", | |
| 517 | + identityLabel: `${displayName} OAuth`, | |
| 518 | + }, | |
| 519 | + }); | |
| 520 | + setStartingOAuth(false); | |
| 521 | + if (Exit.isFailure(exit)) { | |
| 522 | + setOauth2Error(errorMessageFromExit(exit, "Failed to start OAuth")); | |
| 523 | + return; | |
| 524 | + } | |
| 525 | + const response = exit.value; | |
| 526 | + if (!response.completedConnection) { | |
| 527 | + setOauth2Error("client_credentials flow did not mint a connection"); | |
| 528 | + return; | |
| 529 | + } | |
| 530 | + setOauth2AuthState({ | |
| 531 | + fingerprint: selectedOAuth2Fingerprint, | |
| 532 | + auth: new OAuth2Auth({ | |
| 533 | + kind: "oauth2", | |
| 534 | + connectionId: response.completedConnection.connectionId, | |
| 535 | + securitySchemeName: selectedOAuth2Preset.securitySchemeName, | |
| 536 | + flow: "clientCredentials", | |
| 537 | + tokenUrl, | |
| 538 | + authorizationUrl: null, | |
| 499 | 539 | clientIdSecretId: oauth2ClientIdSecretId, |
| 500 | 540 | clientSecretSecretId: oauth2ClientSecretSecretId, |
| 501 | 541 | scopes: [...oauth2SelectedScopes], |
| 502 | − }, | |
| 503 | − pluginId: "openapi", | |
| 504 | − identityLabel: `${displayName} OAuth`, | |
| 505 | − }, | |
| 506 | − }); | |
| 507 | − setStartingOAuth(false); | |
| 508 | − if (Exit.isFailure(startOAuthExit)) { | |
| 509 | − setOauth2Error(errorMessageFromExit(startOAuthExit, "Failed to start OAuth")); | |
| 510 | − return; | |
| 511 | − } | |
| 512 | − const response = startOAuthExit.value; | |
| 513 | − if (!response.completedConnection) { | |
| 514 | − setOauth2Error("client_credentials flow did not mint a connection"); | |
| 542 | + }), | |
| 543 | + }); | |
| 544 | + setOauth2Error(null); | |
| 515 | 545 | return; |
| 516 | − } | |
| 517 | − setOauth2AuthState({ | |
| 518 | − fingerprint: selectedOAuth2Fingerprint, | |
| 519 | − auth: new OAuth2Auth({ | |
| 520 | − kind: "oauth2", | |
| 521 | − connectionId: response.completedConnection.connectionId, | |
| 522 | − securitySchemeName: selectedOAuth2Preset.securitySchemeName, | |
| 523 | − flow: "clientCredentials", | |
| 524 | − tokenUrl, | |
| 525 | − authorizationUrl: null, | |
| 526 | − clientIdSecretId: oauth2ClientIdSecretId, | |
| 527 | − clientSecretSecretId: oauth2ClientSecretSecretId, | |
| 528 | − scopes: [...oauth2SelectedScopes], | |
| 529 | − }), | |
| 530 | − }); | |
| 531 | − setOauth2Error(null); | |
| 532 | − return; | |
| 533 | 546 | } |
| 534 | 547 | |
| 535 | 548 | const authorizationUrl = resolveOAuthUrl( |
| @@ -537,61 +550,66 @@ export default function AddOpenApiSource(props: { · 1 unmodified line | ||
| 537 | 550 | resolvedBaseUrl, |
| 538 | 551 | ); |
| 539 | 552 | const issuerUrl = inferOAuthIssuerUrl(authorizationUrl); |
| 540 | − const startOAuthExit = await doStartOAuth({ | |
| 541 | − params: { scopeId }, | |
| 542 | − payload: { | |
| 543 | − endpoint: authorizationUrl, | |
| 544 | − connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow), | |
| 545 | − tokenScope: scopeId, | |
| 546 | − redirectUrl: oauth2RedirectUrl, | |
| 547 | − strategy: { | |
| 548 | − kind: "authorization-code", | |
| 549 | − authorizationEndpoint: authorizationUrl, | |
| 550 | − tokenEndpoint: tokenUrl, | |
| 551 | − issuerUrl, | |
| 552 | − clientIdSecretId: oauth2ClientIdSecretId, | |
| 553 | − clientSecretSecretId: oauth2ClientSecretSecretId ?? null, | |
| 554 | − scopes: [...oauth2SelectedScopes], | |
| 555 | − }, | |
| 556 | − pluginId: "openapi", | |
| 557 | − identityLabel: `${displayName} OAuth`, | |
| 558 | − }, | |
| 559 | − }); | |
| 560 | − if (Exit.isFailure(startOAuthExit)) { | |
| 561 | − setOauth2Error(errorMessageFromExit(startOAuthExit, "Failed to start OAuth")); | |
| 562 | − return; | |
| 563 | − } | |
| 564 | − const response = startOAuthExit.value; | |
| 565 | − if (response.authorizationUrl === null) { | |
| 566 | − setOauth2Error("Unexpected response flow from server"); | |
| 567 | − return; | |
| 568 | − } | |
| 569 | 553 | |
| 570 | 554 | await oauth.openAuthorization({ |
| 571 | − run: async () => ({ | |
| 572 | − sessionId: response.sessionId, | |
| 573 | − authorizationUrl: response.authorizationUrl, | |
| 574 | − }), | |
| 575 | − onSuccess: (result) => { | |
| 576 | − setOauth2AuthState({ | |
| 577 | − fingerprint: selectedOAuth2Fingerprint, | |
| 578 | − auth: new OAuth2Auth({ | |
| 579 | − kind: "oauth2", | |
| 580 | − connectionId: result.connectionId, | |
| 581 | − securitySchemeName: selectedOAuth2Preset.securitySchemeName, | |
| 582 | − flow: "authorizationCode", | |
| 583 | − tokenUrl, | |
| 584 | − authorizationUrl, | |
| 585 | − issuerUrl, | |
| 586 | − clientIdSecretId: oauth2ClientIdSecretId, | |
| 587 | − clientSecretSecretId: oauth2ClientSecretSecretId, | |
| 588 | − scopes: [...oauth2SelectedScopes], | |
| 589 | − }), | |
| 555 | + run: async () => { | |
| 556 | + const exit = await doStartOAuth({ | |
| 557 | + params: { scopeId }, | |
| 558 | + payload: { | |
| 559 | + endpoint: authorizationUrl, | |
| 560 | + connectionId: openApiOAuthConnectionId(resolvedSourceId, selectedOAuth2Preset.flow), | |
| 561 | + tokenScope: scopeId, | |
| 562 | + redirectUrl: oauth2RedirectUrl, | |
| 563 | + strategy: { | |
| 564 | + kind: "authorization-code", | |
| 565 | + authorizationEndpoint: authorizationUrl, | |
| 566 | + tokenEndpoint: tokenUrl, | |
| 567 | + issuerUrl, | |
| 568 | + clientIdSecretId: oauth2ClientIdSecretId, | |
| 569 | + clientSecretSecretId: oauth2ClientSecretSecretId ?? null, | |
| 570 | + scopes: [...oauth2SelectedScopes], | |
| 571 | + }, | |
| 572 | + pluginId: "openapi", | |
| 573 | + identityLabel: `${displayName} OAuth`, | |
| 574 | + }, | |
| 590 | 575 | }); |
| 591 | − setOauth2Error(null); | |
| 592 | − }, | |
| 593 | − onError: setOauth2Error, | |
| 594 | − }); | |
| 576 | + if (Exit.isFailure(exit)) { | |
| 577 | + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run() | |
| 578 | + throw new Error(errorMessageFromExit(exit, "Failed to start OAuth")); | |
| 579 | + } | |
| 580 | + const response = exit.value; | |
| 581 | + if (response.authorizationUrl === null) { | |
| 582 | + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: OAuth popup API represents start failure by rejecting run() | |
| 583 | + throw new Error("Unexpected response flow from server"); | |
| 584 | + } | |
| 585 | + return { | |
| 586 | + sessionId: response.sessionId, | |
| 587 | + authorizationUrl: response.authorizationUrl, | |
| 588 | + }; | |
| 589 | + }, | |
| 590 | + onSuccess: (result) => { | |
| 591 | + setOauth2AuthState({ | |
| 592 | + fingerprint: selectedOAuth2Fingerprint, | |
| 593 | + auth: new OAuth2Auth({ | |
| 594 | + kind: "oauth2", | |
| 595 | + connectionId: result.connectionId, | |
| 596 | + securitySchemeName: selectedOAuth2Preset.securitySchemeName, | |
| 597 | + flow: "authorizationCode", | |
| 598 | + tokenUrl, | |
| 599 | + authorizationUrl, | |
| 600 | + issuerUrl, | |
| 601 | + clientIdSecretId: oauth2ClientIdSecretId, | |
| 602 | + clientSecretSecretId: oauth2ClientSecretSecretId, | |
| 603 | + scopes: [...oauth2SelectedScopes], | |
| 604 | + }), | |
| 605 | + }); | |
| 606 | + setOauth2Error(null); | |
| 607 | + }, | |
| 608 | + onError: (message) => { | |
| 609 | + setStartingOAuth(false); | |
| 610 | + setOauth2Error(message); | |
| 611 | + }, | |
| 612 | + }); | |
| 595 | 613 | }, [ |
| 596 | 614 | selectedOAuth2Preset, |
| 597 | 615 | oauth2ClientIdSecretId, |
| @@ -621,24 +639,13 @@ export default function AddOpenApiSource(props: { · 23 unmodified lines | ||
| 621 | 639 | const displayName = |
| 622 | 640 | identity.name.trim() || |
| 623 | 641 | (preview ? Option.getOrElse(preview.title, () => namespace) : namespace); |
| 624 | − const placeholder = beginAdd({ | |
| 625 | − id: namespace, | |
| 626 | − name: displayName, | |
| 627 | − kind: "openapi", | |
| 628 | − url: resolvedBaseUrl || undefined, | |
| 629 | − }); | |
| 630 | − const failAdd = (message: string) => { | |
| 631 | − setAddError(message); | |
| 632 | − setAdding(false); | |
| 633 | − placeholder.done(); | |
| 634 | − }; | |
| 635 | − const resultExit = await doAdd({ | |
| 642 | + const exit = await doAdd({ | |
| 636 | 643 | params: { scopeId }, |
| 637 | 644 | payload: { |
| 638 | 645 | spec: specUrl, |
| 639 | 646 | specFetchCredentials: serializeHttpCredentials(specFetchCredentials), |
| 640 | − name: identity.name.trim() || undefined, | |
| 641 | − namespace: slugifyNamespace(identity.namespace) || undefined, | |
| 647 | + name: displayName, | |
| 648 | + namespace, | |
| 642 | 649 | baseUrl: resolvedBaseUrl || undefined, |
| 643 | 650 | ...(hasHeaders ? { headers: configuredHeaders } : {}), |
| 644 | 651 | ...(Object.keys(serializeHttpCredentials(runtimeCredentials).queryParams).length > 0 |
| @@ -648,13 +655,13 @@ export default function AddOpenApiSource(props: { · 3 unmodified lines | ||
| 648 | 655 | }, |
| 649 | 656 | reactivityKeys: addSpecWriteKeys, |
| 650 | 657 | }); |
| 651 | − | |
| 652 | − if (Exit.isFailure(resultExit)) { | |
| 653 | − failAdd(errorMessageFromExit(resultExit, "Failed to add source")); | |
| 658 | + if (Exit.isFailure(exit)) { | |
| 659 | + setAddError(errorMessageFromExit(exit, "Failed to add source")); | |
| 660 | + setAdding(false); | |
| 654 | 661 | return; |
| 655 | 662 | } |
| 656 | 663 | |
| 657 | − const sourceId = resultExit.value.namespace; | |
| 664 | + const sourceId = exit.value.namespace; | |
| 658 | 665 | const sourceScope = ScopeId.make(scopeId); |
| 659 | 666 | const bindingScope = ScopeId.make(userScope); |
| 660 | 667 | |
| @@ -674,13 +681,14 @@ export default function AddOpenApiSource(props: { · 13 unmodified lines | ||
| 674 | 681 | reactivityKeys: bindingWriteKeys, |
| 675 | 682 | }); |
| 676 | 683 | if (Exit.isFailure(bindingExit)) { |
| 677 | − failAdd(errorMessageFromExit(bindingExit, "Failed to add source")); | |
| 684 | + setAddError(errorMessageFromExit(bindingExit, "Failed to add source")); | |
| 685 | + setAdding(false); | |
| 678 | 686 | return; |
| 679 | 687 | } |
| 680 | 688 | } |
| 681 | 689 | |
| 682 | 690 | if (configuredOAuth2 && oauth2ClientIdSecretId) { |
| 683 | − const clientIdBindingExit = await doSetBinding({ | |
| 691 | + const bindingExit = await doSetBinding({ | |
| 684 | 692 | params: { scopeId }, |
| 685 | 693 | payload: { |
| 686 | 694 | sourceId, |
| @@ -694,14 +702,15 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines | ||
| 694 | 702 | }, |
| 695 | 703 | reactivityKeys: bindingWriteKeys, |
| 696 | 704 | }); |
| 697 | − if (Exit.isFailure(clientIdBindingExit)) { | |
| 698 | − failAdd(errorMessageFromExit(clientIdBindingExit, "Failed to add source")); | |
| 705 | + if (Exit.isFailure(bindingExit)) { | |
| 706 | + setAddError(errorMessageFromExit(bindingExit, "Failed to add source")); | |
| 707 | + setAdding(false); | |
| 699 | 708 | return; |
| 700 | 709 | } |
| 701 | 710 | } |
| 702 | 711 | |
| 703 | 712 | if (configuredOAuth2?.clientSecretSlot && oauth2ClientSecretSecretId) { |
| 704 | − const clientSecretBindingExit = await doSetBinding({ | |
| 713 | + const bindingExit = await doSetBinding({ | |
| 705 | 714 | params: { scopeId }, |
| 706 | 715 | payload: { |
| 707 | 716 | sourceId, |
| @@ -715,14 +724,15 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines | ||
| 715 | 724 | }, |
| 716 | 725 | reactivityKeys: bindingWriteKeys, |
| 717 | 726 | }); |
| 718 | − if (Exit.isFailure(clientSecretBindingExit)) { | |
| 719 | − failAdd(errorMessageFromExit(clientSecretBindingExit, "Failed to add source")); | |
| 727 | + if (Exit.isFailure(bindingExit)) { | |
| 728 | + setAddError(errorMessageFromExit(bindingExit, "Failed to add source")); | |
| 729 | + setAdding(false); | |
| 720 | 730 | return; |
| 721 | 731 | } |
| 722 | 732 | } |
| 723 | 733 | |
| 724 | 734 | if (configuredOAuth2 && oauth2Auth) { |
| 725 | − const connectionBindingExit = await doSetBinding({ | |
| 735 | + const bindingExit = await doSetBinding({ | |
| 726 | 736 | params: { scopeId }, |
| 727 | 737 | payload: { |
| 728 | 738 | sourceId, |
| @@ -736,13 +746,13 @@ export default function AddOpenApiSource(props: { · 7 unmodified lines | ||
| 736 | 746 | }, |
| 737 | 747 | reactivityKeys: bindingWriteKeys, |
| 738 | 748 | }); |
| 739 | − if (Exit.isFailure(connectionBindingExit)) { | |
| 740 | − failAdd(errorMessageFromExit(connectionBindingExit, "Failed to add source")); | |
| 749 | + if (Exit.isFailure(bindingExit)) { | |
| 750 | + setAddError(errorMessageFromExit(bindingExit, "Failed to add source")); | |
| 751 | + setAdding(false); | |
| 741 | 752 | return; |
| 742 | 753 | } |
| 743 | 754 | } |
| 744 | 755 | |
| 745 | − placeholder.done(); | |
| 746 | 756 | props.onComplete(); |
| 747 | 757 | }; |
| 748 | 758 | |
| @@ -1,4 +1,7 @@ | ||
| 1 | 1 | import type { ScopeId } from "@executor-js/sdk/core"; |
| 2 | +import * as Atom from "effect/unstable/reactivity/Atom"; | |
| 3 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 4 | +import { sourcesOptimisticAtom } from "@executor-js/react/api/atoms"; | |
| 2 | 5 | import { ReactivityKey } from "@executor-js/react/api/reactivity-keys"; |
| 3 | 6 | import { OpenApiClient } from "./client"; |
| 4 | 7 | |
| @@ -32,6 +35,33 @@ export const previewOpenApiSpec = OpenApiClient.mutation("openapi", "previewSpec · 27 unmodified lines | ||
| 32 | 35 | |
| 33 | 36 | export const addOpenApiSpec = OpenApiClient.mutation("openapi", "addSpec"); |
| 34 | 37 | |
| 38 | +export const addOpenApiSpecOptimistic = Atom.family((scopeId: ScopeId) => | |
| 39 | + sourcesOptimisticAtom(scopeId).pipe( | |
| 40 | + Atom.optimisticFn({ | |
| 41 | + reducer: (current, arg) => | |
| 42 | + AsyncResult.map(current, (rows) => { | |
| 43 | + const id = arg.payload.namespace ?? `pending-${Math.random().toString(36).slice(2)}`; | |
| 44 | + const source = { | |
| 45 | + id, | |
| 46 | + scopeId, | |
| 47 | + kind: "openapi", | |
| 48 | + pluginId: "openapi", | |
| 49 | + name: arg.payload.name ?? id, | |
| 50 | + ...(arg.payload.baseUrl ? { url: arg.payload.baseUrl } : {}), | |
| 51 | + canRemove: false, | |
| 52 | + canRefresh: false, | |
| 53 | + canEdit: false, | |
| 54 | + runtime: false, | |
| 55 | + }; | |
| 56 | + return [source, ...rows.filter((row) => row.id !== id)].sort((a, b) => | |
| 57 | + a.name.localeCompare(b.name), | |
| 58 | + ); | |
| 59 | + }), | |
| 60 | + fn: addOpenApiSpec, | |
| 61 | + }), | |
| 62 | + ), | |
| 63 | +); | |
| 64 | + | |
| 35 | 65 | export const updateOpenApiSource = OpenApiClient.mutation("openapi", "updateSource"); |
| 36 | 66 | |
| 37 | 67 | export const setOpenApiSourceBinding = OpenApiClient.mutation("openapi", "setSourceBinding"); |
| @@ -55,7 +55,10 @@ export const sourcesAtom = (scopeId: ScopeId) => · 54 unmodified lines | ||
| 55 | 55 | |
| 56 | 56 | /** Single source by id — derived from the sources list */ |
| 57 | 57 | export const sourceAtom = (sourceId: string, scopeId: ScopeId) => |
| 58 | − Atom.mapResult(sourcesAtom(scopeId), (sources) => sources.find((s) => s.id === sourceId) ?? null); | |
| 58 | + Atom.mapResult( | |
| 59 | + sourcesOptimisticAtom(scopeId), | |
| 60 | + (sources) => sources.find((s) => s.id === sourceId) ?? null, | |
| 61 | + ); | |
| 59 | 62 | |
| 60 | 63 | export const secretsAtom = (scopeId: ScopeId) => |
| 61 | 64 | ExecutorApiClient.query("secrets", "list", { |
| @@ -85,25 +88,14 @@ export const secretUsagesAtom = (scopeId: ScopeId, secretId: SecretId) => · 23 unmodified lines | ||
| 85 | 88 | // Refresh whenever any source / connection / secret changes — adding |
| 86 | 89 | // an oauth source pulls in a new connection-secret link and we want |
| 87 | 90 | // the usage list to reflect it. |
| 88 | − reactivityKeys: [ | |
| 89 | − ReactivityKey.secrets, | |
| 90 | − ReactivityKey.sources, | |
| 91 | − ReactivityKey.connections, | |
| 92 | − ], | |
| 91 | + reactivityKeys: [ReactivityKey.secrets, ReactivityKey.sources, ReactivityKey.connections], | |
| 93 | 92 | }); |
| 94 | 93 | |
| 95 | −export const connectionUsagesAtom = ( | |
| 96 | − scopeId: ScopeId, | |
| 97 | − connectionId: ConnectionId, | |
| 98 | −) => | |
| 94 | +export const connectionUsagesAtom = (scopeId: ScopeId, connectionId: ConnectionId) => | |
| 99 | 95 | ExecutorApiClient.query("connections", "usages", { |
| 100 | 96 | params: { scopeId, connectionId }, |
| 101 | 97 | timeToLive: "30 seconds", |
| 102 | − reactivityKeys: [ | |
| 103 | − ReactivityKey.connections, | |
| 104 | − ReactivityKey.sources, | |
| 105 | − ReactivityKey.secrets, | |
| 106 | − ], | |
| 98 | + reactivityKeys: [ReactivityKey.connections, ReactivityKey.sources, ReactivityKey.secrets], | |
| 107 | 99 | }); |
| 108 | 100 | |
| 109 | 101 | export const policiesAtom = (scopeId: ScopeId) => |
| @@ -153,6 +145,46 @@ export const updatePolicy = ExecutorApiClient.mutation("policies", "update"); · 43 unmodified lines | ||
| 153 | 145 | |
| 154 | 146 | export const removePolicy = ExecutorApiClient.mutation("policies", "remove"); |
| 155 | 147 | |
| 148 | +// --------------------------------------------------------------------------- | |
| 149 | +// Sources — optimistic surface. | |
| 150 | +// --------------------------------------------------------------------------- | |
| 151 | + | |
| 152 | +export const sourcesOptimisticAtom = Atom.family((scopeId: ScopeId) => | |
| 153 | + Atom.optimistic(sourcesAtom(scopeId)), | |
| 154 | +); | |
| 155 | + | |
| 156 | +export const removeSourceOptimistic = Atom.family((scopeId: ScopeId) => | |
| 157 | + sourcesOptimisticAtom(scopeId).pipe( | |
| 158 | + Atom.optimisticFn({ | |
| 159 | + reducer: (current, arg) => | |
| 160 | + AsyncResult.map(current, (rows) => | |
| 161 | + rows.filter((source) => source.id !== arg.params.sourceId), | |
| 162 | + ), | |
| 163 | + fn: removeSource, | |
| 164 | + }), | |
| 165 | + ), | |
| 166 | +); | |
| 167 | + | |
| 168 | +// --------------------------------------------------------------------------- | |
| 169 | +// Connections — optimistic removals. | |
| 170 | +// --------------------------------------------------------------------------- | |
| 171 | + | |
| 172 | +export const connectionsOptimisticAtom = Atom.family((scopeId: ScopeId) => | |
| 173 | + Atom.optimistic(connectionsAtom(scopeId)), | |
| 174 | +); | |
| 175 | + | |
| 176 | +export const removeConnectionOptimistic = Atom.family((scopeId: ScopeId) => | |
| 177 | + connectionsOptimisticAtom(scopeId).pipe( | |
| 178 | + Atom.optimisticFn({ | |
| 179 | + reducer: (current, arg) => | |
| 180 | + AsyncResult.map(current, (rows) => | |
| 181 | + rows.filter((connection) => connection.id !== arg.params.connectionId), | |
| 182 | + ), | |
| 183 | + fn: removeConnection, | |
| 184 | + }), | |
| 185 | + ), | |
| 186 | +); | |
| 187 | + | |
| 156 | 188 | // --------------------------------------------------------------------------- |
| 157 | 189 | // Secrets — optimistic removals. |
| 158 | 190 | // --------------------------------------------------------------------------- |
| @@ -1,213 +0,0 @@ | ||
| 1 | −import * as React from "react"; | |
| 2 | −import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 3 | −import * as Atom from "effect/unstable/reactivity/Atom"; | |
| 4 | −import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 5 | −import type { ScopeId } from "@executor-js/sdk"; | |
| 6 | − | |
| 7 | −import { connectionsAtom, sourcesAtom } from "./atoms"; | |
| 8 | − | |
| 9 | −/** | |
| 10 | − * Client-only optimistic-update layer. | |
| 11 | − * | |
| 12 | − * Mutations that add a row return only after the server roundtrip. The | |
| 13 | − * `pendingResourceAtom` family lets a UI insert a placeholder *before* the | |
| 14 | − * mutation resolves so the sidebar/list updates instantly. Once the server | |
| 15 | − * returns and reactivity refetches the canonical list, callers should remove | |
| 16 | − * the placeholder (the dedupe in `mergePending` filters it anyway, but | |
| 17 | − * explicit removal keeps the pending set bounded on errors). | |
| 18 | − * | |
| 19 | − * Resource keys (`"sources"`, `"secrets"`, …) match the strings in | |
| 20 | − * `ReactivityKey` but are intentionally not coupled — pending state is purely | |
| 21 | − * client-side. | |
| 22 | − */ | |
| 23 | − | |
| 24 | −export const PendingResource = { | |
| 25 | − sources: "sources", | |
| 26 | − connectionRemovals: "connection-removals", | |
| 27 | −} as const; | |
| 28 | − | |
| 29 | −export interface PendingEntry<T> { | |
| 30 | − readonly id: string; | |
| 31 | − readonly value: T; | |
| 32 | −} | |
| 33 | − | |
| 34 | −const pendingFamily = Atom.family((_resource: string) => | |
| 35 | − Atom.make<ReadonlyArray<PendingEntry<unknown>>>([]), | |
| 36 | −); | |
| 37 | − | |
| 38 | −const atomFor = <T,>(resource: string) => | |
| 39 | − pendingFamily(resource) as ReturnType<typeof pendingFamily> & { | |
| 40 | − readonly __t?: T; | |
| 41 | − }; | |
| 42 | − | |
| 43 | −export const usePendingResource = <T,>(resource: string) => { | |
| 44 | − const atom = atomFor<T>(resource); | |
| 45 | − const pending = useAtomValue(atom) as ReadonlyArray<PendingEntry<T>>; | |
| 46 | − const setPending = useAtomSet(atom); | |
| 47 | − | |
| 48 | − const add = React.useCallback( | |
| 49 | − (entry: PendingEntry<T>) => | |
| 50 | − setPending((prev) => [ | |
| 51 | − ...(prev as ReadonlyArray<PendingEntry<T>>).filter((p) => p.id !== entry.id), | |
| 52 | − entry, | |
| 53 | − ]), | |
| 54 | − [setPending], | |
| 55 | − ); | |
| 56 | − | |
| 57 | − const remove = React.useCallback( | |
| 58 | − (id: string) => | |
| 59 | − setPending((prev) => (prev as ReadonlyArray<PendingEntry<T>>).filter((p) => p.id !== id)), | |
| 60 | − [setPending], | |
| 61 | − ); | |
| 62 | − | |
| 63 | − const clear = React.useCallback(() => setPending([]), [setPending]); | |
| 64 | − | |
| 65 | − return { pending, add, remove, clear }; | |
| 66 | −}; | |
| 67 | − | |
| 68 | −/** | |
| 69 | − * Merges pending entries with a server-loaded list. Pending entries whose id | |
| 70 | − * already exists on the server are dropped so we never double-render a row | |
| 71 | − * once the canonical version arrives. | |
| 72 | − */ | |
| 73 | −export const mergePending = <T, R>( | |
| 74 | − pending: ReadonlyArray<PendingEntry<T>>, | |
| 75 | − server: ReadonlyArray<R>, | |
| 76 | − serverId: (row: R) => string, | |
| 77 | − fromPending: (entry: PendingEntry<T>) => R, | |
| 78 | −): ReadonlyArray<R> => { | |
| 79 | − if (pending.length === 0) return server; | |
| 80 | − const seen = new Set(server.map(serverId)); | |
| 81 | − const extras = pending.filter((p) => !seen.has(p.id)).map(fromPending); | |
| 82 | − if (extras.length === 0) return server; | |
| 83 | − return [...extras, ...server]; | |
| 84 | −}; | |
| 85 | − | |
| 86 | −// --------------------------------------------------------------------------- | |
| 87 | −// Sources — convenience wrappers used by sidebar/list views and add forms. | |
| 88 | −// Pending entries are global (not per-scope) because the user only sees one | |
| 89 | −// scope at a time; this keeps the add-form code free of scope plumbing. | |
| 90 | −// --------------------------------------------------------------------------- | |
| 91 | − | |
| 92 | −export interface PendingSource { | |
| 93 | − readonly name: string; | |
| 94 | − readonly kind: string; | |
| 95 | − readonly url?: string; | |
| 96 | −} | |
| 97 | − | |
| 98 | −/** | |
| 99 | − * Sidebar/list helper. Reads `sourcesAtom(scopeId)` and merges any pending | |
| 100 | − * placeholder rows in, sorting the combined list by name so the placeholder | |
| 101 | − * lands in the same position the canonical row will occupy — no visual jump | |
| 102 | − * when the server confirms. | |
| 103 | − */ | |
| 104 | −export const useSourcesWithPending = (scopeId: ScopeId) => { | |
| 105 | − const result = useAtomValue(sourcesAtom(scopeId)); | |
| 106 | − const { pending } = usePendingResource<PendingSource>(PendingResource.sources); | |
| 107 | − return React.useMemo( | |
| 108 | − () => | |
| 109 | − AsyncResult.map( | |
| 110 | − result, | |
| 111 | − ( | |
| 112 | − sources: ReadonlyArray<{ | |
| 113 | − readonly id: string; | |
| 114 | − readonly name: string; | |
| 115 | − readonly kind: string; | |
| 116 | − readonly url?: string; | |
| 117 | − }>, | |
| 118 | − ) => { | |
| 119 | − const merged = mergePending( | |
| 120 | − pending, | |
| 121 | − sources, | |
| 122 | − (s: { readonly id: string }) => s.id, | |
| 123 | − (p) => ({ | |
| 124 | − id: p.id, | |
| 125 | − name: p.value.name, | |
| 126 | − kind: p.value.kind, | |
| 127 | − url: p.value.url, | |
| 128 | − // The placeholder cannot be removed/refreshed/edited until the | |
| 129 | − // server confirms it, so disable those affordances. | |
| 130 | − canRemove: false, | |
| 131 | − canRefresh: false, | |
| 132 | − canEdit: false, | |
| 133 | − }), | |
| 134 | − ); | |
| 135 | − return [...merged].sort((a, b) => a.name.localeCompare(b.name)); | |
| 136 | − }, | |
| 137 | − ), | |
| 138 | − [result, pending], | |
| 139 | − ); | |
| 140 | −}; | |
| 141 | − | |
| 142 | −/** | |
| 143 | − * Hook for an add form. Returns helpers to push and clear a pending source | |
| 144 | − * placeholder. On submit: | |
| 145 | − * const placeholder = beginAdd({ id: namespace, name, kind }); | |
| 146 | − * try { await doAdd(...); } finally { placeholder.done(); } | |
| 147 | − */ | |
| 148 | −export const usePendingSources = () => { | |
| 149 | − const { add, remove } = usePendingResource<PendingSource>(PendingResource.sources); | |
| 150 | − return React.useMemo( | |
| 151 | − () => ({ | |
| 152 | − beginAdd: (entry: { id: string } & PendingSource) => { | |
| 153 | − add({ id: entry.id, value: { name: entry.name, kind: entry.kind, url: entry.url } }); | |
| 154 | − return { done: () => remove(entry.id) }; | |
| 155 | − }, | |
| 156 | − }), | |
| 157 | − [add, remove], | |
| 158 | − ); | |
| 159 | −}; | |
| 160 | − | |
| 161 | −// --------------------------------------------------------------------------- | |
| 162 | −// Connections — optimistic removals. | |
| 163 | −// --------------------------------------------------------------------------- | |
| 164 | − | |
| 165 | −interface PendingConnectionRemoval { | |
| 166 | − readonly id: string; | |
| 167 | −} | |
| 168 | − | |
| 169 | −export const useConnectionsWithPendingRemovals = (scopeId: ScopeId) => { | |
| 170 | − const result = useAtomValue(connectionsAtom(scopeId)); | |
| 171 | − const { pending, remove } = usePendingResource<PendingConnectionRemoval>( | |
| 172 | − PendingResource.connectionRemovals, | |
| 173 | − ); | |
| 174 | − | |
| 175 | − React.useEffect(() => { | |
| 176 | − if (!AsyncResult.isSuccess(result) || pending.length === 0) return; | |
| 177 | − | |
| 178 | − const serverIds = new Set( | |
| 179 | − result.value.map((connection: { readonly id: string }) => connection.id), | |
| 180 | − ); | |
| 181 | − for (const entry of pending) { | |
| 182 | − if (!serverIds.has(entry.id)) remove(entry.id); | |
| 183 | − } | |
| 184 | − }, [result, pending, remove]); | |
| 185 | − | |
| 186 | − return React.useMemo( | |
| 187 | − () => | |
| 188 | − AsyncResult.map(result, (connections) => { | |
| 189 | − if (pending.length === 0) return connections; | |
| 190 | − const hiddenIds = new Set(pending.map((entry) => entry.id)); | |
| 191 | − return connections.filter( | |
| 192 | − (connection: { readonly id: string }) => !hiddenIds.has(connection.id), | |
| 193 | − ); | |
| 194 | − }), | |
| 195 | − [result, pending], | |
| 196 | − ); | |
| 197 | −}; | |
| 198 | − | |
| 199 | −export const usePendingConnectionRemovals = () => { | |
| 200 | − const { add, remove } = usePendingResource<PendingConnectionRemoval>( | |
| 201 | − PendingResource.connectionRemovals, | |
| 202 | − ); | |
| 203 | − | |
| 204 | − return React.useMemo( | |
| 205 | − () => ({ | |
| 206 | − beginRemove: (id: string) => { | |
| 207 | − add({ id, value: { id } }); | |
| 208 | − return { undo: () => remove(id) }; | |
| 209 | − }, | |
| 210 | − }), | |
| 211 | − [add, remove], | |
| 212 | − ); | |
| 213 | −}; | |
| @@ -4,7 +4,7 @@ import { useAtomValue } from "@effect/atom-react"; · 3 unmodified lines | ||
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | 5 | import { PlusIcon } from "lucide-react"; |
| 6 | 6 | import { SourceFavicon } from "./source-favicon"; |
| 7 | −import { sourcesAtom } from "../api/atoms"; | |
| 7 | +import { sourcesOptimisticAtom } from "../api/atoms"; | |
| 8 | 8 | import { useScope } from "../hooks/use-scope"; |
| 9 | 9 | import { useSourcePlugins } from "@executor-js/sdk/client"; |
| 10 | 10 | import { |
| @@ -32,7 +32,7 @@ export function CommandPalette() { · 21 unmodified lines | ||
| 32 | 32 | const [open, setOpen] = useState(false); |
| 33 | 33 | const navigate = useNavigate(); |
| 34 | 34 | const scopeId = useScope(); |
| 35 | − const sourcesResult = useAtomValue(sourcesAtom(scopeId)); | |
| 35 | + const sourcesResult = useAtomValue(sourcesOptimisticAtom(scopeId)); | |
| 36 | 36 | |
| 37 | 37 | // Toggle with ⌘K / Ctrl+K |
| 38 | 38 | useEffect(() => { |
| @@ -7,8 +7,11 @@ import * as Schema from "effect/Schema"; · 6 unmodified lines | ||
| 7 | 7 | import { ConnectionId, ConnectionInUseError, type ScopeId } from "@executor-js/sdk"; |
| 8 | 8 | import { toast } from "sonner"; |
| 9 | 9 | |
| 10 | −import { connectionUsagesAtom, removeConnection } from "../api/atoms"; | |
| 11 | −import { useConnectionsWithPendingRemovals, usePendingConnectionRemovals } from "../api/optimistic"; | |
| 10 | +import { | |
| 11 | + connectionUsagesAtom, | |
| 12 | + connectionsOptimisticAtom, | |
| 13 | + removeConnectionOptimistic, | |
| 14 | +} from "../api/atoms"; | |
| 12 | 15 | import { connectionWriteKeys } from "../api/reactivity-keys"; |
| 13 | 16 | import { useScope, useScopeStack } from "../hooks/use-scope"; |
| 14 | 17 | import { Badge } from "../components/badge"; |
| @@ -62,13 +65,8 @@ const connectionScopeLabel = ( · 47 unmodified lines | ||
| 62 | 65 | // don't get a stray "Used by 0" line before any source binds to them. |
| 63 | 66 | // --------------------------------------------------------------------------- |
| 64 | 67 | |
| 65 | −function ConnectionUsageFooter(props: { | |
| 66 | − scopeId: ScopeId; | |
| 67 | − connectionId: ConnectionId; | |
| 68 | −}) { | |
| 69 | − const usages = useAtomValue( | |
| 70 | − connectionUsagesAtom(props.scopeId, props.connectionId), | |
| 71 | − ); | |
| 68 | +function ConnectionUsageFooter(props: { scopeId: ScopeId; connectionId: ConnectionId }) { | |
| 69 | + const usages = useAtomValue(connectionUsagesAtom(props.scopeId, props.connectionId)); | |
| 72 | 70 | return AsyncResult.match(usages, { |
| 73 | 71 | onInitial: () => null, |
| 74 | 72 | onFailure: () => null, |
| @@ -164,18 +162,15 @@ function ConnectionRow(props: { · 89 unmodified lines | ||
| 164 | 162 | export function ConnectionsPage() { |
| 165 | 163 | const scopeId = useScope(); |
| 166 | 164 | const scopeStack = useScopeStack(); |
| 167 | − const connections = useConnectionsWithPendingRemovals(scopeId); | |
| 168 | − const { beginRemove } = usePendingConnectionRemovals(); | |
| 169 | − const doRemove = useAtomSet(removeConnection, { mode: "promiseExit" }); | |
| 165 | + const connections = useAtomValue(connectionsOptimisticAtom(scopeId)); | |
| 166 | + const doRemove = useAtomSet(removeConnectionOptimistic(scopeId), { mode: "promiseExit" }); | |
| 170 | 167 | |
| 171 | 168 | const handleRemove = async (connectionId: string) => { |
| 172 | − const pending = beginRemove(connectionId); | |
| 173 | 169 | const exit = await doRemove({ |
| 174 | 170 | params: { scopeId, connectionId: ConnectionId.make(connectionId) }, |
| 175 | 171 | reactivityKeys: connectionWriteKeys, |
| 176 | 172 | }); |
| 177 | 173 | if (Exit.isFailure(exit)) { |
| 178 | − pending.undo(); | |
| 179 | 174 | const error = Exit.findErrorOption(exit); |
| 180 | 175 | if (Option.isSome(error) && isConnectionInUseError(error.value)) { |
| 181 | 176 | const count = error.value.usageCount; |
| @@ -1,15 +1,15 @@ | ||
| 1 | 1 | import { Suspense, useEffect, useMemo, useState } from "react"; |
| 2 | 2 | import { useNavigate } from "@tanstack/react-router"; |
| 3 | 3 | import { useAtomValue, useAtomSet, useAtomRefresh } from "@effect/atom-react"; |
| 4 | −import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 5 | 4 | import * as Exit from "effect/Exit"; |
| 5 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 6 | 6 | import { effectivePolicyFromSorted } from "@executor-js/sdk"; |
| 7 | 7 | import { |
| 8 | 8 | policiesOptimisticAtom, |
| 9 | 9 | sourceToolsAtom, |
| 10 | − sourcesAtom, | |
| 10 | + sourcesOptimisticAtom, | |
| 11 | 11 | sourceAtom, |
| 12 | − removeSource, | |
| 12 | + removeSourceOptimistic, | |
| 13 | 13 | refreshSource, |
| 14 | 14 | } from "../api/atoms"; |
| 15 | 15 | import { sourceWriteKeys } from "../api/reactivity-keys"; |
| @@ -30,9 +30,9 @@ export function SourceDetailPage(props: { namespace: string }) { · 14 unmodified lines | ||
| 30 | 30 | const source = useAtomValue(sourceAtom(namespace, scopeId)); |
| 31 | 31 | const tools = useAtomValue(sourceToolsAtom(namespace, scopeId)); |
| 32 | 32 | const policies = useAtomValue(policiesOptimisticAtom(scopeId)); |
| 33 | − const refreshSources = useAtomRefresh(sourcesAtom(scopeId)); | |
| 33 | + const refreshSources = useAtomRefresh(sourcesOptimisticAtom(scopeId)); | |
| 34 | 34 | const refreshTools = useAtomRefresh(sourceToolsAtom(namespace, scopeId)); |
| 35 | − const doRemove = useAtomSet(removeSource, { mode: "promiseExit" }); | |
| 35 | + const doRemove = useAtomSet(removeSourceOptimistic(scopeId), { mode: "promiseExit" }); | |
| 36 | 36 | const doRefresh = useAtomSet(refreshSource, { mode: "promiseExit" }); |
| 37 | 37 | const policyActions = usePolicyActions(scopeId); |
| 38 | 38 | const navigate = useNavigate(); |
| @@ -1,17 +1,12 @@ | ||
| 1 | 1 | import { Suspense, useCallback, useMemo, useState } from "react"; |
| 2 | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | −import { useAtomSet } from "@effect/atom-react"; | |
| 3 | +import { useAtomSet, useAtomValue } from "@effect/atom-react"; | |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | 5 | import * as Exit from "effect/Exit"; |
| 6 | 6 | import { PlusIcon } from "lucide-react"; |
| 7 | 7 | import type { SourceDetectionResult } from "@executor-js/sdk"; |
| 8 | −import { | |
| 9 | − useSourcePlugins, | |
| 10 | − type SourcePlugin, | |
| 11 | − type SourcePreset, | |
| 12 | −} from "@executor-js/sdk/client"; | |
| 13 | −import { detectSource } from "../api/atoms"; | |
| 14 | −import { useSourcesWithPending } from "../api/optimistic"; | |
| 8 | +import { useSourcePlugins, type SourcePlugin, type SourcePreset } from "@executor-js/sdk/client"; | |
| 9 | +import { detectSource, sourcesOptimisticAtom } from "../api/atoms"; | |
| 15 | 10 | import { useScope } from "../hooks/use-scope"; |
| 16 | 11 | import { McpInstallCard } from "../components/mcp-install-card"; |
| 17 | 12 | import { Button } from "../components/button"; |
| @@ -61,7 +56,7 @@ const bestDetection = ( · 43 unmodified lines | ||
| 61 | 56 | |
| 62 | 57 | export function SourcesPage() { |
| 63 | 58 | const scopeId = useScope(); |
| 64 | − const sources = useSourcesWithPending(scopeId); | |
| 59 | + const sources = useAtomValue(sourcesOptimisticAtom(scopeId)); | |
| 65 | 60 | const [connectOpen, setConnectOpen] = useState(false); |
| 66 | 61 | |
| 67 | 62 | return ( |
| @@ -230,10 +225,7 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => · 162 unmodified lines | ||
| 230 | 225 | className="flex-1" |
| 231 | 226 | /> |
| 232 | 227 | {isUrl && ( |
| 233 | − <Button | |
| 234 | − onClick={() => void handleDetect()} | |
| 235 | − disabled={detecting || !query.trim()} | |
| 236 | − > | |
| 228 | + <Button onClick={() => void handleDetect()} disabled={detecting || !query.trim()}> | |
| 237 | 229 | {detecting ? "Detecting..." : "Detect"} |
| 238 | 230 | </Button> |
| 239 | 231 | )} |
| @@ -258,11 +250,7 @@ function ConnectDialog(props: { open: boolean; onOpenChange: (open: boolean) => · 18 unmodified lines | ||
| 258 | 250 | </div> |
| 259 | 251 | </div> |
| 260 | 252 | |
| 261 | − <PresetGrid | |
| 262 | − plugins={sourcePlugins} | |
| 263 | − onPick={closeAndReset} | |
| 264 | − searchQuery={presetSearch} | |
| 265 | − /> | |
| 253 | + <PresetGrid plugins={sourcePlugins} onPick={closeAndReset} searchQuery={presetSearch} /> | |
| 266 | 254 | </div> |
| 267 | 255 | </DialogContent> |
| 268 | 256 | </Dialog> |
| @@ -326,8 +314,7 @@ function PresetGrid(props: { · 57 unmodified lines | ||
| 326 | 314 | const q = (props.searchQuery ?? "").trim().toLowerCase(); |
| 327 | 315 | if (q.length === 0) return allPresets; |
| 328 | 316 | return allPresets.filter(({ preset, pluginLabel }) => { |
| 329 | − const corpus = | |
| 330 | − `${preset.name} ${preset.summary ?? ""} ${pluginLabel}`.toLowerCase(); | |
| 317 | + const corpus = `${preset.name} ${preset.summary ?? ""} ${pluginLabel}`.toLowerCase(); | |
| 331 | 318 | return corpus.includes(q); |
| 332 | 319 | }); |
| 333 | 320 | }, [allPresets, props.searchQuery]); |
| @@ -61,17 +61,13 @@ paths = [ · 60 unmodified lines | ||
| 61 | 61 | [[skills]] |
| 62 | 62 | name = "wrdn-gha-workflows" |
| 63 | 63 | remote = "getsentry/warden-skills" |
| 64 | −paths = [ | |
| 65 | − ".github/workflows/**/*.yml", | |
| 66 | − ".github/workflows/**/*.yaml", | |
| 67 | −] | |
| 64 | +paths = [".github/workflows/**/*.yml", ".github/workflows/**/*.yaml"] | |
| 68 | 65 | |
| 69 | 66 | # Local skill — no `remote =`, resolved from `.agents/skills/<name>/SKILL.md`. |
| 70 | 67 | [[skills]] |
| 71 | 68 | name = "wrdn-effect-atom-optimistic" |
| 72 | 69 | paths = [ |
| 73 | 70 | "packages/react/src/api/atoms.tsx", |
| 74 | − "packages/react/src/api/optimistic.tsx", | |
| 75 | 71 | "packages/react/src/pages/**/*.tsx", |
| 76 | 72 | "packages/react/src/components/**/*.tsx", |
| 77 | 73 | ] |