Replace 30 scattered `useAtomRefresh()` call sites with effect-atom's `reactivityKeys` invalidation. Every query declares its key at the atom definition; every mutation passes the matching key at the call site, and effect-atom's Reactivity service auto-refetches on mutation completion. Adds a client-only optimistic-update layer (`usePendingSources` + `useSourcesWithPending`) so the sidebar shows a placeholder row the instant a user submits an add form, rather than waiting for the network roundtrip. A CI lint script (`lint:reactivity-keys`) fails any build that defines a write mutation call without `reactivityKeys`, locking the convention in. Key files: - packages/react/src/api/reactivity-keys.tsx — canonical key constants - packages/react/src/api/optimistic.tsx — pending-resource atom family - scripts/check-reactivity-keys.ts — CI enforcement
Unified · Split
| @@ -1,9 +1,14 @@ | ||
| 1 | 1 | import { useReducer, useState } from "react"; |
| 2 | 2 | import { Exit } from "effect"; |
| 3 | 3 | import { createFileRoute, Link } from "@tanstack/react-router"; |
| 4 | −import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; | |
| 4 | +import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; | |
| 5 | 5 | import { useCustomer } from "autumn-js/react"; |
| 6 | 6 | import { toast } from "sonner"; |
| 7 | +import { | |
| 8 | + orgMemberWriteKeys, | |
| 9 | + orgDomainWriteKeys, | |
| 10 | + orgInfoWriteKeys, | |
| 11 | +} from "@executor/react/api/reactivity-keys"; | |
| 7 | 12 | import { |
| 8 | 13 | Dialog, |
| 9 | 14 | DialogContent, |
| @@ -46,7 +51,7 @@ import { · 36 unmodified lines | ||
| 46 | 51 | deleteDomain, |
| 47 | 52 | updateOrgName, |
| 48 | 53 | } from "../web/org-atoms"; |
| 49 | −import { authAtom, useAuth } from "../web/auth"; | |
| 54 | +import { useAuth } from "../web/auth"; | |
| 50 | 55 | |
| 51 | 56 | export const Route = createFileRoute("/org")({ |
| 52 | 57 | component: OrgPage, |
| @@ -109,8 +114,6 @@ function OrgPage() { · 56 unmodified lines | ||
| 109 | 114 | const membersResult = useAtomValue(orgMembersAtom); |
| 110 | 115 | const rolesResult = useAtomValue(orgRolesAtom); |
| 111 | 116 | const domainsResult = useAtomValue(orgDomainsAtom); |
| 112 | − const refreshMembers = useAtomRefresh(orgMembersAtom); | |
| 113 | − const refreshDomains = useAtomRefresh(orgDomainsAtom); | |
| 114 | 117 | const doRemove = useAtomSet(removeMember, { mode: "promiseExit" }); |
| 115 | 118 | const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" }); |
| 116 | 119 | const doDeleteDomain = useAtomSet(deleteDomain, { mode: "promiseExit" }); |
| @@ -130,27 +133,27 @@ function OrgPage() { · 13 unmodified lines | ||
| 130 | 133 | }); |
| 131 | 134 | |
| 132 | 135 | const handleRemove = async (membershipId: string, name: string) => { |
| 133 | − const exit = await doRemove({ path: { membershipId } }); | |
| 136 | + const exit = await doRemove({ path: { membershipId }, reactivityKeys: orgMemberWriteKeys }); | |
| 134 | 137 | if (Exit.isSuccess(exit)) { |
| 135 | 138 | toast.success(`Removed ${name}`); |
| 136 | − refreshMembers(); | |
| 137 | 139 | } else { |
| 138 | 140 | toast.error("Failed to remove member"); |
| 139 | 141 | } |
| 140 | 142 | }; |
| 141 | 143 | |
| 142 | 144 | const handleChangeRole = async (membershipId: string, roleSlug: string, roleName: string) => { |
| 143 | − const exit = await doUpdateRole({ path: { membershipId }, payload: { roleSlug } }); | |
| 145 | + const exit = await doUpdateRole({ | |
| 146 | + path: { membershipId }, | |
| 147 | + payload: { roleSlug }, | |
| 148 | + reactivityKeys: orgMemberWriteKeys, | |
| 149 | + }); | |
| 144 | 150 | if (Exit.isSuccess(exit)) { |
| 145 | 151 | toast.success(`Role changed to ${roleName}`); |
| 146 | − refreshMembers(); | |
| 147 | 152 | } else { |
| 148 | 153 | toast.error("Failed to change role"); |
| 149 | 154 | } |
| 150 | 155 | }; |
| 151 | 156 | |
| 152 | − const refreshAuth = useAtomRefresh(authAtom); | |
| 153 | − | |
| 154 | 157 | const handleSaveName = async () => { |
| 155 | 158 | const trimmed = editName.trim(); |
| 156 | 159 | if (!trimmed || trimmed === orgName) { |
| @@ -158,10 +161,12 @@ function OrgPage() { · 1 unmodified line | ||
| 158 | 161 | return; |
| 159 | 162 | } |
| 160 | 163 | setSavingName(true); |
| 161 | − const exit = await doUpdateOrgName({ payload: { name: trimmed } }); | |
| 164 | + const exit = await doUpdateOrgName({ | |
| 165 | + payload: { name: trimmed }, | |
| 166 | + reactivityKeys: orgInfoWriteKeys, | |
| 167 | + }); | |
| 162 | 168 | if (Exit.isSuccess(exit)) { |
| 163 | 169 | toast.success("Organization name updated"); |
| 164 | − refreshAuth(); | |
| 165 | 170 | } else { |
| 166 | 171 | toast.error("Failed to update organization name"); |
| 167 | 172 | setEditName(orgName); |
| @@ -170,17 +175,19 @@ function OrgPage() { · 2 unmodified lines | ||
| 170 | 175 | }; |
| 171 | 176 | |
| 172 | 177 | const handleDeleteDomain = async (domainId: string, domain: string) => { |
| 173 | − const exit = await doDeleteDomain({ path: { domainId } }); | |
| 178 | + const exit = await doDeleteDomain({ | |
| 179 | + path: { domainId }, | |
| 180 | + reactivityKeys: orgDomainWriteKeys, | |
| 181 | + }); | |
| 174 | 182 | if (Exit.isSuccess(exit)) { |
| 175 | 183 | toast.success(`Removed ${domain}`); |
| 176 | − refreshDomains(); | |
| 177 | 184 | } else { |
| 178 | 185 | toast.error("Failed to remove domain"); |
| 179 | 186 | } |
| 180 | 187 | }; |
| 181 | 188 | |
| 182 | 189 | const handleAddDomain = async () => { |
| 183 | − const exit = await doGetVerificationLink({}); | |
| 190 | + const exit = await doGetVerificationLink({ reactivityKeys: orgDomainWriteKeys }); | |
| 184 | 191 | if (Exit.isSuccess(exit)) { |
| 185 | 192 | window.open(exit.value.link, "_blank"); |
| 186 | 193 | } else { |
| @@ -473,7 +480,6 @@ function OrgPage() { · 286 unmodified lines | ||
| 473 | 480 | <InviteDialog |
| 474 | 481 | open={inviteOpen} |
| 475 | 482 | onOpenChange={setInviteOpen} |
| 476 | − onInvited={refreshMembers} | |
| 477 | 483 | roles={roles} |
| 478 | 484 | /> |
| 479 | 485 | </div> |
| @@ -580,7 +586,6 @@ function DomainCard({ · 100 unmodified lines | ||
| 580 | 586 | function InviteDialog(props: { |
| 581 | 587 | open: boolean; |
| 582 | 588 | onOpenChange: (v: boolean) => void; |
| 583 | − onInvited: () => void; | |
| 584 | 589 | roles: readonly { slug: string; name: string }[]; |
| 585 | 590 | }) { |
| 586 | 591 | const [state, dispatch] = useReducer(inviteReducer, initialInviteState); |
| @@ -595,13 +600,13 @@ function InviteDialog(props: { · 8 unmodified lines | ||
| 595 | 600 | email: state.email.trim(), |
| 596 | 601 | ...(state.roleSlug ? { roleSlug: state.roleSlug } : {}), |
| 597 | 602 | }, |
| 603 | + reactivityKeys: orgMemberWriteKeys, | |
| 598 | 604 | }); |
| 599 | 605 | |
| 600 | 606 | if (Exit.isSuccess(exit)) { |
| 601 | 607 | toast.success(`Invitation sent to ${state.email.trim()}`); |
| 602 | 608 | dispatch({ type: "reset" }); |
| 603 | 609 | props.onOpenChange(false); |
| 604 | − props.onInvited(); | |
| 605 | 610 | } else { |
| 606 | 611 | dispatch({ type: "error", message: "Failed to send invitation" }); |
| 607 | 612 | } |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import React, { createContext, useContext } from "react"; |
| 2 | 2 | import { Atom } from "@effect-atom/atom"; |
| 3 | 3 | import { useAtomValue, Result } from "@effect-atom/atom-react"; |
| 4 | +import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | |
| 4 | 5 | |
| 5 | 6 | import { CloudApiClient } from "./client"; |
| 6 | 7 | |
| @@ -26,10 +27,14 @@ type AuthOrganization = { · 19 unmodified lines | ||
| 26 | 27 | |
| 27 | 28 | export const authAtom = CloudApiClient.query("cloudAuth", "me", { |
| 28 | 29 | timeToLive: "5 minutes", |
| 30 | + reactivityKeys: [ReactivityKey.auth], | |
| 29 | 31 | }); |
| 30 | 32 | |
| 31 | 33 | export const organizationsAtom = Atom.refreshOnWindowFocus( |
| 32 | − CloudApiClient.query("cloudAuth", "organizations", { timeToLive: "1 minute" }), | |
| 34 | + CloudApiClient.query("cloudAuth", "organizations", { | |
| 35 | + timeToLive: "1 minute", | |
| 36 | + reactivityKeys: [ReactivityKey.auth], | |
| 37 | + }), | |
| 33 | 38 | ); |
| 34 | 39 | |
| 35 | 40 | export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization"); |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { useState } from "react"; |
| 2 | 2 | import { useAtomSet } from "@effect-atom/atom-react"; |
| 3 | +import { authWriteKeys } from "@executor/react/api/reactivity-keys"; | |
| 3 | 4 | import { Input } from "@executor/react/components/input"; |
| 4 | 5 | import { Label } from "@executor/react/components/label"; |
| 5 | 6 | |
| @@ -31,7 +32,7 @@ export function useCreateOrganizationForm(options: { · 25 unmodified lines | ||
| 31 | 32 | } |
| 32 | 33 | setCreating(true); |
| 33 | 34 | setError(null); |
| 34 | − const exit = await doCreate({ payload: { name: trimmed } }); | |
| 35 | + const exit = await doCreate({ payload: { name: trimmed }, reactivityKeys: authWriteKeys }); | |
| 35 | 36 | setCreating(false); |
| 36 | 37 | if (exit._tag === "Success") { |
| 37 | 38 | options.onSuccess(exit.value); |
| @@ -1,14 +1,17 @@ | ||
| 1 | 1 | import { Atom } from "@effect-atom/atom"; |
| 2 | +import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | |
| 2 | 3 | import { CloudApiClient } from "./client"; |
| 3 | 4 | |
| 4 | 5 | export const orgMembersAtom = Atom.refreshOnWindowFocus( |
| 5 | 6 | CloudApiClient.query("org", "listMembers", { |
| 6 | 7 | timeToLive: "30 seconds", |
| 8 | + reactivityKeys: [ReactivityKey.orgMembers], | |
| 7 | 9 | }), |
| 8 | 10 | ); |
| 9 | 11 | |
| 10 | 12 | export const orgRolesAtom = CloudApiClient.query("org", "listRoles", { |
| 11 | 13 | timeToLive: "5 minutes", |
| 14 | + reactivityKeys: [ReactivityKey.orgMembers], | |
| 12 | 15 | }); |
| 13 | 16 | |
| 14 | 17 | export const inviteMember = CloudApiClient.mutation("org", "invite"); |
| @@ -20,6 +23,7 @@ export const updateMemberRole = CloudApiClient.mutation("org", "updateMemberRole · 5 unmodified lines | ||
| 20 | 23 | export const orgDomainsAtom = Atom.refreshOnWindowFocus( |
| 21 | 24 | CloudApiClient.query("org", "listDomains", { |
| 22 | 25 | timeToLive: "30 seconds", |
| 26 | + reactivityKeys: [ReactivityKey.orgDomains], | |
| 23 | 27 | }), |
| 24 | 28 | ); |
| 25 | 29 | |
| @@ -1,8 +1,7 @@ | ||
| 1 | −import { useAtomRefresh } from "@effect-atom/atom-react"; | |
| 2 | 1 | import { Button } from "@executor/react/components/button"; |
| 3 | 2 | |
| 4 | 3 | import { AUTH_PATHS } from "../../auth/api"; |
| 5 | −import { authAtom, useAuth } from "../auth"; | |
| 4 | +import { useAuth } from "../auth"; | |
| 6 | 5 | import { |
| 7 | 6 | CreateOrganizationFields, |
| 8 | 7 | useCreateOrganizationForm, |
| @@ -10,7 +9,6 @@ import { · 1 unmodified line | ||
| 10 | 9 | |
| 11 | 10 | export const OnboardingPage = () => { |
| 12 | 11 | const auth = useAuth(); |
| 13 | − const refreshAuth = useAtomRefresh(authAtom); | |
| 14 | 12 | |
| 15 | 13 | const suggestedName = |
| 16 | 14 | auth.status === "authenticated" && |
| @@ -21,13 +19,10 @@ export const OnboardingPage = () => { · 4 unmodified lines | ||
| 21 | 19 | |
| 22 | 20 | const form = useCreateOrganizationForm({ |
| 23 | 21 | defaultName: suggestedName, |
| 24 | − // On success: the server set a new cookie with the new org; refetch /me | |
| 25 | − // so AuthGate routes into Shell. | |
| 26 | − // On failure: the server may have cleared the cookie because the current | |
| 27 | − // session was too stale to attach the new org. Refetch /me regardless so | |
| 28 | − // AuthGate can route to LoginPage if that's the case. | |
| 29 | − onSuccess: () => refreshAuth(), | |
| 30 | − onFailure: () => refreshAuth(), | |
| 22 | + // The server sets a cookie for the new (or cleared) org on both success and | |
| 23 | + // failure. The authWriteKeys passed at the mutation site auto-invalidate | |
| 24 | + // authAtom so AuthGate re-routes into Shell or LoginPage as appropriate. | |
| 25 | + onSuccess: () => {}, | |
| 31 | 26 | }); |
| 32 | 27 | |
| 33 | 28 | return ( |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; |
| 2 | 2 | import { useEffect, useRef, useState } from "react"; |
| 3 | 3 | import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; |
| 4 | −import { sourcesAtom } from "@executor/react/api/atoms"; | |
| 4 | +import { useSourcesWithPending } from "@executor/react/api/optimistic"; | |
| 5 | 5 | import { useScope } from "@executor/react/api/scope-context"; |
| 6 | 6 | import { Button } from "@executor/react/components/button"; |
| 7 | 7 | import { Skeleton } from "@executor/react/components/skeleton"; |
| @@ -66,7 +66,7 @@ function NavItem(props: { to: string; label: string; active: boolean; onNavigate · 58 unmodified lines | ||
| 66 | 66 | |
| 67 | 67 | function SourceList(props: { pathname: string; onNavigate?: () => void }) { |
| 68 | 68 | const scopeId = useScope(); |
| 69 | − const sources = useAtomValue(sourcesAtom(scopeId)); | |
| 69 | + const sources = useSourcesWithPending(scopeId); | |
| 70 | 70 | |
| 71 | 71 | return Result.match(sources, { |
| 72 | 72 | onInitial: () => ( |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { Link, Outlet, useLocation } from "@tanstack/react-router"; |
| 2 | 2 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 3 | −import { useAtomRefresh, useAtomValue, Result } from "@effect-atom/atom-react"; | |
| 3 | +import { useAtomRefresh, Result } from "@effect-atom/atom-react"; | |
| 4 | 4 | import { sourcesAtom, toolsAtom } from "@executor/react/api/atoms"; |
| 5 | +import { useSourcesWithPending } from "@executor/react/api/optimistic"; | |
| 5 | 6 | import { useScope, useScopeInfo } from "@executor/react/api/scope-context"; |
| 6 | 7 | import { Button } from "@executor/react/components/button"; |
| 7 | 8 | import { SourceFavicon } from "@executor/react/components/source-favicon"; |
| @@ -224,7 +225,7 @@ function NavItem(props: { to: string; label: string; active: boolean; onNavigate · 216 unmodified lines | ||
| 224 | 225 | |
| 225 | 226 | function SourceList(props: { pathname: string; onNavigate?: () => void }) { |
| 226 | 227 | const scopeId = useScope(); |
| 227 | − const sources = useAtomValue(sourcesAtom(scopeId)); | |
| 228 | + const sources = useSourcesWithPending(scopeId); | |
| 228 | 229 | |
| 229 | 230 | return Result.match(sources, { |
| 230 | 231 | onInitial: () => ( |
| @@ -37,7 +37,8 @@ · 36 unmodified lines | ||
| 37 | 37 | "typecheck": "turbo run typecheck", |
| 38 | 38 | "typecheck:slow": "turbo run typecheck:slow", |
| 39 | 39 | "ci": "bun run lint && bun run typecheck && bun run test", |
| 40 | − "lint": "oxlint -c .oxlintrc.jsonc . --deny-warnings", | |
| 40 | + "lint": "oxlint -c .oxlintrc.jsonc . --deny-warnings && bun run lint:reactivity-keys", | |
| 41 | + "lint:reactivity-keys": "bun run scripts/check-reactivity-keys.ts", | |
| 41 | 42 | "lint:fix": "oxlint -c .oxlintrc.jsonc --fix .", |
| 42 | 43 | "format": "oxfmt .", |
| 43 | 44 | "format:check": "oxfmt --check .", |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 2 | −import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react"; | |
| 2 | +import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; | |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | openOAuthPopup, |
| @@ -7,6 +7,8 @@ import { · 1 unmodified line | ||
| 7 | 7 | } from "@executor/plugin-oauth2/react"; |
| 8 | 8 | |
| 9 | 9 | import { secretsAtom, setSecret } from "@executor/react/api/atoms"; |
| 10 | +import { usePendingSources } from "@executor/react/api/optimistic"; | |
| 11 | +import { secretWriteKeys, sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | |
| 10 | 12 | import { useScope } from "@executor/react/api/scope-context"; |
| 11 | 13 | import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker"; |
| 12 | 14 | import { SecretId } from "@executor/sdk"; |
| @@ -64,7 +66,6 @@ function InlineCreateSecret(props: { · 51 unmodified lines | ||
| 64 | 66 | const [error, setError] = useState<string | null>(null); |
| 65 | 67 | const scopeId = useScope(); |
| 66 | 68 | const doSet = useAtomSet(setSecret, { mode: "promise" }); |
| 67 | − const refreshSecrets = useAtomRefresh(secretsAtom(scopeId)); | |
| 68 | 69 | |
| 69 | 70 | const handleSave = async () => { |
| 70 | 71 | if (!secretId.trim() || !secretValue.trim()) return; |
| @@ -78,8 +79,8 @@ function InlineCreateSecret(props: { · 7 unmodified lines | ||
| 78 | 79 | name: secretName.trim() || secretId.trim(), |
| 79 | 80 | value: secretValue.trim(), |
| 80 | 81 | }, |
| 82 | + reactivityKeys: [...secretWriteKeys], | |
| 81 | 83 | }); |
| 82 | − refreshSecrets(); | |
| 83 | 84 | props.onCreated(secretId.trim()); |
| 84 | 85 | } catch (e) { |
| 85 | 86 | setError(e instanceof Error ? e.message : "Failed to save secret"); |
| @@ -417,6 +418,7 @@ export default function AddGoogleDiscoverySource(props: { · 331 unmodified lines | ||
| 417 | 418 | const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promise" }); |
| 418 | 419 | const doStartOAuth = useAtomSet(startGoogleDiscoveryOAuth, { mode: "promise" }); |
| 419 | 420 | const secrets = useAtomValue(secretsAtom(scopeId)); |
| 421 | + const { beginAdd } = usePendingSources(); | |
| 420 | 422 | |
| 421 | 423 | const canUseOAuth = useMemo(() => (probe?.scopes.length ?? 0) > 0, [probe]); |
| 422 | 424 | const secretList: readonly SecretPickerSecret[] = Result.match(secrets, { |
| @@ -556,11 +558,18 @@ export default function AddGoogleDiscoverySource(props: { · 133 unmodified lines | ||
| 556 | 558 | if (!probe) return; |
| 557 | 559 | setAdding(true); |
| 558 | 560 | setError(null); |
| 561 | + const displayName = identity.name.trim() || probe.name; | |
| 562 | + const namespace = slugifyNamespace(identity.namespace) || probe.name; | |
| 563 | + const placeholder = beginAdd({ | |
| 564 | + id: namespace, | |
| 565 | + name: displayName, | |
| 566 | + kind: "google-discovery", | |
| 567 | + }); | |
| 559 | 568 | try { |
| 560 | 569 | await doAdd({ |
| 561 | 570 | path: { scopeId }, |
| 562 | 571 | payload: { |
| 563 | − name: identity.name.trim() || probe.name, | |
| 572 | + name: displayName, | |
| 564 | 573 | discoveryUrl: discoveryUrl.trim(), |
| 565 | 574 | namespace: slugifyNamespace(identity.namespace) || undefined, |
| 566 | 575 | auth: |
| @@ -568,13 +577,16 @@ export default function AddGoogleDiscoverySource(props: { · 1 unmodified line | ||
| 568 | 577 | ? (oauthAuth ?? { kind: "none" as const }) |
| 569 | 578 | : { kind: "none" as const }, |
| 570 | 579 | }, |
| 580 | + reactivityKeys: [...sourceWriteKeys], | |
| 571 | 581 | }); |
| 572 | 582 | props.onComplete(); |
| 573 | 583 | } catch (e) { |
| 574 | 584 | setError(e instanceof Error ? e.message : "Failed to add source"); |
| 575 | 585 | setAdding(false); |
| 586 | + } finally { | |
| 587 | + placeholder.done(); | |
| 576 | 588 | } |
| 577 | − }, [probe, doAdd, identity, discoveryUrl, authKind, oauthAuth, props, scopeId]); | |
| 589 | + }, [probe, doAdd, identity, discoveryUrl, authKind, oauthAuth, props, scopeId, beginAdd]); | |
| 578 | 590 | |
| 579 | 591 | const addDisabled = |
| 580 | 592 | !probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null)); |
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | import type { ScopeId } from "@executor/sdk"; |
| 2 | +import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | |
| 2 | 3 | import { GoogleDiscoveryClient } from "./client"; |
| 3 | 4 | |
| 4 | 5 | export const googleDiscoverySourceAtom = (scopeId: ScopeId, namespace: string) => |
| 5 | 6 | GoogleDiscoveryClient.query("googleDiscovery", "getSource", { |
| 6 | 7 | path: { scopeId, namespace }, |
| 7 | 8 | timeToLive: "15 seconds", |
| 9 | + reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools], | |
| 8 | 10 | }); |
| 9 | 11 | |
| 10 | 12 | export const probeGoogleDiscovery = GoogleDiscoveryClient.mutation( |
| @@ -2,6 +2,8 @@ import { useState } from "react"; · 1 unmodified line | ||
| 2 | 2 | import { useAtomSet } from "@effect-atom/atom-react"; |
| 3 | 3 | |
| 4 | 4 | import { useScope } from "@executor/react/api/scope-context"; |
| 5 | +import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | |
| 6 | +import { usePendingSources } from "@executor/react/api/optimistic"; | |
| 5 | 7 | import { HeadersList } from "@executor/react/plugins/headers-list"; |
| 6 | 8 | import { type HeaderState } from "@executor/react/plugins/secret-header-auth"; |
| 7 | 9 | import { |
| @@ -46,6 +48,7 @@ export default function AddGraphqlSource(props: { · 38 unmodified lines | ||
| 46 | 48 | |
| 47 | 49 | const scopeId = useScope(); |
| 48 | 50 | const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" }); |
| 51 | + const { beginAdd } = usePendingSources(); | |
| 49 | 52 | const secretList = useSecretPickerSecrets(); |
| 50 | 53 | |
| 51 | 54 | const headersValid = headers.every((header) => header.name.trim() && header.secretId); |
| @@ -54,31 +57,47 @@ export default function AddGraphqlSource(props: { · 2 unmodified lines | ||
| 54 | 57 | const handleAdd = async () => { |
| 55 | 58 | setAdding(true); |
| 56 | 59 | setAddError(null); |
| 57 | − try { | |
| 58 | − const headerMap: Record<string, HeaderValue> = {}; | |
| 59 | − for (const header of headers) { | |
| 60 | − const name = header.name.trim(); | |
| 61 | − if (name && header.secretId) { | |
| 62 | − headerMap[name] = { | |
| 63 | − secretId: header.secretId, | |
| 64 | − ...(header.prefix ? { prefix: header.prefix } : {}), | |
| 65 | − }; | |
| 66 | − } | |
| 60 | + const headerMap: Record<string, HeaderValue> = {}; | |
| 61 | + for (const header of headers) { | |
| 62 | + const name = header.name.trim(); | |
| 63 | + if (name && header.secretId) { | |
| 64 | + headerMap[name] = { | |
| 65 | + secretId: header.secretId, | |
| 66 | + ...(header.prefix ? { prefix: header.prefix } : {}), | |
| 67 | + }; | |
| 67 | 68 | } |
| 69 | + } | |
| 68 | 70 | |
| 71 | + const trimmedEndpoint = endpoint.trim(); | |
| 72 | + const namespace = | |
| 73 | + slugifyNamespace(identity.namespace) || | |
| 74 | + slugifyNamespace(displayNameFromUrl(trimmedEndpoint) ?? "") || | |
| 75 | + "graphql"; | |
| 76 | + const displayName = | |
| 77 | + identity.name.trim() || displayNameFromUrl(trimmedEndpoint) || namespace; | |
| 78 | + const placeholder = beginAdd({ | |
| 79 | + id: namespace, | |
| 80 | + name: displayName, | |
| 81 | + kind: "graphql", | |
| 82 | + url: trimmedEndpoint || undefined, | |
| 83 | + }); | |
| 84 | + try { | |
| 69 | 85 | await doAdd({ |
| 70 | 86 | path: { scopeId }, |
| 71 | 87 | payload: { |
| 72 | − endpoint: endpoint.trim(), | |
| 88 | + endpoint: trimmedEndpoint, | |
| 73 | 89 | name: identity.name.trim() || undefined, |
| 74 | 90 | namespace: slugifyNamespace(identity.namespace) || undefined, |
| 75 | 91 | ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}), |
| 76 | 92 | }, |
| 93 | + reactivityKeys: sourceWriteKeys, | |
| 77 | 94 | }); |
| 78 | 95 | props.onComplete(); |
| 79 | 96 | } catch (e) { |
| 80 | 97 | setAddError(e instanceof Error ? e.message : "Failed to add source"); |
| 81 | 98 | setAdding(false); |
| 99 | + } finally { | |
| 100 | + placeholder.done(); | |
| 82 | 101 | } |
| 83 | 102 | }; |
| 84 | 103 | |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { useState } from "react"; |
| 2 | −import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; | |
| 2 | +import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; | |
| 3 | 3 | import { graphqlSourceAtom, updateGraphqlSource } from "./atoms"; |
| 4 | 4 | import { useScope } from "@executor/react/api/scope-context"; |
| 5 | +import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | |
| 5 | 6 | import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; |
| 6 | 7 | import { |
| 7 | 8 | headerValueToState, |
| @@ -35,7 +36,6 @@ function EditForm(props: { · 27 unmodified lines | ||
| 35 | 36 | }) { |
| 36 | 37 | const scopeId = useScope(); |
| 37 | 38 | const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promise" }); |
| 38 | − const refreshSource = useAtomRefresh(graphqlSourceAtom(scopeId, props.sourceId)); | |
| 39 | 39 | const secretList = useSecretPickerSecrets(); |
| 40 | 40 | |
| 41 | 41 | const identity = useSourceIdentity({ |
| @@ -70,8 +70,8 @@ function EditForm(props: { · 28 unmodified lines | ||
| 70 | 70 | endpoint: endpoint.trim() || undefined, |
| 71 | 71 | headers: headersFromState(headers), |
| 72 | 72 | }, |
| 73 | + reactivityKeys: sourceWriteKeys, | |
| 73 | 74 | }); |
| 74 | − refreshSource(); | |
| 75 | 75 | setDirty(false); |
| 76 | 76 | props.onSave(); |
| 77 | 77 | } catch (e) { |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { ScopeId } from "@executor/sdk"; |
| 2 | +import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | |
| 2 | 3 | import { GraphqlClient } from "./client"; |
| 3 | 4 | |
| 4 | 5 | // --------------------------------------------------------------------------- |
| @@ -9,6 +10,7 @@ export const graphqlSourceAtom = (scopeId: ScopeId, namespace: string) => · 4 unmodified lines | ||
| 9 | 10 | GraphqlClient.query("graphql", "getSource", { |
| 10 | 11 | path: { scopeId, namespace }, |
| 11 | 12 | timeToLive: "15 seconds", |
| 13 | + reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools], | |
| 12 | 14 | }); |
| 13 | 15 | |
| 14 | 16 | // --------------------------------------------------------------------------- |
| @@ -35,6 +35,8 @@ import { · 34 unmodified lines | ||
| 35 | 35 | import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; |
| 36 | 36 | |
| 37 | 37 | type RemoteAuthMode = "none" | "header" | "oauth2"; |
| 38 | +import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | |
| 39 | +import { usePendingSources } from "@executor/react/api/optimistic"; | |
| 38 | 40 | import { probeMcpEndpoint, addMcpSource, startMcpOAuth } from "./atoms"; |
| 39 | 41 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 40 | 42 | |
| @@ -330,6 +332,7 @@ export default function AddMcpSource(props: { · 289 unmodified lines | ||
| 330 | 332 | const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" }); |
| 331 | 333 | const doAdd = useAtomSet(addMcpSource, { mode: "promise" }); |
| 332 | 334 | const doStartOAuth = useAtomSet(startMcpOAuth, { mode: "promise" }); |
| 335 | + const { beginAdd } = usePendingSources(); | |
| 333 | 336 | const secretList = useSecretPickerSecrets(); |
| 334 | 337 | |
| 335 | 338 | const [remoteAuthMode, setRemoteAuthMode] = useState<RemoteAuthMode>("none"); |
| @@ -462,42 +465,51 @@ export default function AddMcpSource(props: { · 126 unmodified lines | ||
| 462 | 465 | const handleAddRemote = useCallback(async () => { |
| 463 | 466 | if (!probe) return; |
| 464 | 467 | dispatch({ type: "add-start" }); |
| 465 | − try { | |
| 466 | − const headerAuth = remoteAuthHeaders[0]; | |
| 467 | − const auth = | |
| 468 | − remoteAuthMode === "header" && headerAuth?.secretId | |
| 468 | + const headerAuth = remoteAuthHeaders[0]; | |
| 469 | + const auth = | |
| 470 | + remoteAuthMode === "header" && headerAuth?.secretId | |
| 471 | + ? { | |
| 472 | + kind: "header" as const, | |
| 473 | + headerName: headerAuth.name.trim(), | |
| 474 | + secretId: headerAuth.secretId, | |
| 475 | + ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}), | |
| 476 | + } | |
| 477 | + : remoteAuthMode === "oauth2" && tokens | |
| 469 | 478 | ? { |
| 470 | − kind: "header" as const, | |
| 471 | − headerName: headerAuth.name.trim(), | |
| 472 | − secretId: headerAuth.secretId, | |
| 473 | − ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}), | |
| 479 | + kind: "oauth2" as const, | |
| 480 | + accessTokenSecretId: tokens.accessTokenSecretId, | |
| 481 | + refreshTokenSecretId: tokens.refreshTokenSecretId, | |
| 482 | + tokenType: tokens.tokenType, | |
| 483 | + expiresAt: tokens.expiresAt, | |
| 484 | + scope: tokens.scope, | |
| 474 | 485 | } |
| 475 | − : remoteAuthMode === "oauth2" && tokens | |
| 476 | − ? { | |
| 477 | − kind: "oauth2" as const, | |
| 478 | − accessTokenSecretId: tokens.accessTokenSecretId, | |
| 479 | − refreshTokenSecretId: tokens.refreshTokenSecretId, | |
| 480 | − tokenType: tokens.tokenType, | |
| 481 | − expiresAt: tokens.expiresAt, | |
| 482 | − scope: tokens.scope, | |
| 483 | − } | |
| 484 | − : { kind: "none" as const }; | |
| 485 | − const headers = Object.fromEntries( | |
| 486 | − remoteHeaders | |
| 487 | − .map((header) => [header.name.trim(), header.value.trim()] as const) | |
| 488 | − .filter(([name, value]) => name && value), | |
| 489 | − ); | |
| 490 | − | |
| 486 | + : { kind: "none" as const }; | |
| 487 | + const headers = Object.fromEntries( | |
| 488 | + remoteHeaders | |
| 489 | + .map((header) => [header.name.trim(), header.value.trim()] as const) | |
| 490 | + .filter(([name, value]) => name && value), | |
| 491 | + ); | |
| 492 | + const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name; | |
| 493 | + const slugNamespace = slugifyNamespace(remoteIdentity.namespace); | |
| 494 | + const placeholderId = slugNamespace || `pending:${crypto.randomUUID()}`; | |
| 495 | + const placeholder = beginAdd({ | |
| 496 | + id: placeholderId, | |
| 497 | + name: displayName, | |
| 498 | + kind: "mcp", | |
| 499 | + url: state.url.trim(), | |
| 500 | + }); | |
| 501 | + try { | |
| 491 | 502 | await doAdd({ |
| 492 | 503 | path: { scopeId }, |
| 493 | 504 | payload: { |
| 494 | 505 | transport: "remote" as const, |
| 495 | − name: remoteIdentity.name.trim() || probe.serverName || probe.name, | |
| 496 | − namespace: slugifyNamespace(remoteIdentity.namespace) || undefined, | |
| 506 | + name: displayName, | |
| 507 | + namespace: slugNamespace || undefined, | |
| 497 | 508 | endpoint: state.url.trim(), |
| 498 | 509 | auth, |
| 499 | 510 | ...(Object.keys(headers).length > 0 ? { headers } : {}), |
| 500 | 511 | }, |
| 512 | + reactivityKeys: sourceWriteKeys, | |
| 501 | 513 | }); |
| 502 | 514 | props.onComplete(); |
| 503 | 515 | } catch (e) { |
| @@ -505,6 +517,8 @@ export default function AddMcpSource(props: { · 1 unmodified line | ||
| 505 | 517 | type: "add-fail", |
| 506 | 518 | error: e instanceof Error ? e.message : "Failed to add source", |
| 507 | 519 | }); |
| 520 | + } finally { | |
| 521 | + placeholder.done(); | |
| 508 | 522 | } |
| 509 | 523 | }, [ |
| 510 | 524 | probe, |
| @@ -517,6 +531,7 @@ export default function AddMcpSource(props: { · 6 unmodified lines | ||
| 517 | 531 | doAdd, |
| 518 | 532 | props, |
| 519 | 533 | scopeId, |
| 534 | + beginAdd, | |
| 520 | 535 | ]); |
| 521 | 536 | |
| 522 | 537 | // ---- Stdio actions ---- |
| @@ -549,24 +564,35 @@ export default function AddMcpSource(props: { · 26 unmodified lines | ||
| 549 | 564 | if (!cmd) return; |
| 550 | 565 | setStdioAdding(true); |
| 551 | 566 | setStdioError(null); |
| 567 | + const displayName = stdioIdentity.name.trim() || cmd; | |
| 568 | + const slugNamespace = slugifyNamespace(stdioIdentity.namespace); | |
| 569 | + const placeholderId = slugNamespace || `pending:${crypto.randomUUID()}`; | |
| 570 | + const placeholder = beginAdd({ | |
| 571 | + id: placeholderId, | |
| 572 | + name: displayName, | |
| 573 | + kind: "mcp", | |
| 574 | + }); | |
| 552 | 575 | try { |
| 553 | 576 | await doAdd({ |
| 554 | 577 | path: { scopeId }, |
| 555 | 578 | payload: { |
| 556 | 579 | transport: "stdio" as const, |
| 557 | − name: stdioIdentity.name.trim() || cmd, | |
| 558 | − namespace: slugifyNamespace(stdioIdentity.namespace) || undefined, | |
| 580 | + name: displayName, | |
| 581 | + namespace: slugNamespace || undefined, | |
| 559 | 582 | command: cmd, |
| 560 | 583 | args: parseStdioArgs(stdioArgs), |
| 561 | 584 | env: parseStdioEnv(stdioEnv), |
| 562 | 585 | }, |
| 586 | + reactivityKeys: sourceWriteKeys, | |
| 563 | 587 | }); |
| 564 | 588 | props.onComplete(); |
| 565 | 589 | } catch (e) { |
| 566 | 590 | setStdioError(e instanceof Error ? e.message : "Failed to add source"); |
| 567 | 591 | setStdioAdding(false); |
| 592 | + } finally { | |
| 593 | + placeholder.done(); | |
| 568 | 594 | } |
| 569 | − }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props]); | |
| 595 | + }, [stdioCommand, stdioArgs, stdioEnv, stdioIdentity, doAdd, scopeId, props, beginAdd]); | |
| 570 | 596 | |
| 571 | 597 | // ---- Render ---- |
| 572 | 598 | |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { useState } from "react"; |
| 2 | −import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; | |
| 2 | +import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; | |
| 3 | 3 | import { mcpSourceAtom, updateMcpSource } from "./atoms"; |
| 4 | 4 | import { useScope } from "@executor/react/api/scope-context"; |
| 5 | +import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | |
| 5 | 6 | import { |
| 6 | 7 | SourceIdentityFields, |
| 7 | 8 | useSourceIdentity, |
| @@ -37,7 +38,6 @@ function RemoteEditForm(props: { · 29 unmodified lines | ||
| 37 | 38 | }) { |
| 38 | 39 | const scopeId = useScope(); |
| 39 | 40 | const doUpdate = useAtomSet(updateMcpSource, { mode: "promise" }); |
| 40 | − const refreshSource = useAtomRefresh(mcpSourceAtom(scopeId, props.sourceId)); | |
| 41 | 41 | |
| 42 | 42 | const identity = useSourceIdentity({ |
| 43 | 43 | fallbackName: props.initial.name, |
| @@ -90,8 +90,8 @@ function RemoteEditForm(props: { · 46 unmodified lines | ||
| 90 | 90 | endpoint: endpoint.trim() || undefined, |
| 91 | 91 | headers: headersObj, |
| 92 | 92 | }, |
| 93 | + reactivityKeys: sourceWriteKeys, | |
| 93 | 94 | }); |
| 94 | − refreshSource(); | |
| 95 | 95 | setDirty(false); |
| 96 | 96 | props.onSave(); |
| 97 | 97 | } catch (e) { |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { ScopeId } from "@executor/sdk"; |
| 2 | +import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | |
| 2 | 3 | import { McpClient } from "./client"; |
| 3 | 4 | |
| 4 | 5 | // --------------------------------------------------------------------------- |
| @@ -9,6 +10,7 @@ export const mcpSourceAtom = (scopeId: ScopeId, namespace: string) => · 4 unmodified lines | ||
| 9 | 10 | McpClient.query("mcp", "getSource", { |
| 10 | 11 | path: { scopeId, namespace }, |
| 11 | 12 | timeToLive: "15 seconds", |
| 13 | + reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools], | |
| 12 | 14 | }); |
| 13 | 15 | |
| 14 | 16 | // --------------------------------------------------------------------------- |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { useState } from "react"; |
| 2 | −import { useAtomSet, useAtomValue, useAtomRefresh } from "@effect-atom/atom-react"; | |
| 2 | +import { useAtomSet, useAtomValue } from "@effect-atom/atom-react"; | |
| 3 | 3 | import { Result } from "@effect-atom/atom-react"; |
| 4 | +import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | |
| 4 | 5 | import { useScope } from "@executor/react/api/scope-context"; |
| 5 | 6 | import { Button } from "@executor/react/components/button"; |
| 6 | 7 | import { Input } from "@executor/react/components/input"; |
| @@ -139,7 +140,6 @@ function ConfigDialog(props: { · 132 unmodified lines | ||
| 139 | 140 | |
| 140 | 141 | const scopeId = useScope(); |
| 141 | 142 | const doConfigure = useAtomSet(configureOnePassword, { mode: "promise" }); |
| 142 | − const refreshConfig = useAtomRefresh(onepasswordConfigAtom(scopeId)); | |
| 143 | 143 | |
| 144 | 144 | const reset = () => { |
| 145 | 145 | if (!isEdit) { |
| @@ -165,9 +165,9 @@ function ConfigDialog(props: { · 19 unmodified lines | ||
| 165 | 165 | await doConfigure({ |
| 166 | 166 | path: { scopeId }, |
| 167 | 167 | payload: { auth, vaultId: vaultId.trim(), name: vaultName.trim() || "1Password" }, |
| 168 | + reactivityKeys: [ReactivityKey.secrets], | |
| 168 | 169 | }); |
| 169 | 170 | props.onOpenChange(false); |
| 170 | − refreshConfig(); | |
| 171 | 171 | reset(); |
| 172 | 172 | } catch (e) { |
| 173 | 173 | setError(e instanceof Error ? e.message : "Failed to save configuration"); |
| @@ -296,12 +296,10 @@ export default function OnePasswordSettings() { · 122 unmodified lines | ||
| 296 | 296 | const scopeId = useScope(); |
| 297 | 297 | const configResult = useAtomValue(onepasswordConfigAtom(scopeId)); |
| 298 | 298 | const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promise" }); |
| 299 | − const refreshConfig = useAtomRefresh(onepasswordConfigAtom(scopeId)); | |
| 300 | 299 | |
| 301 | 300 | const handleRemove = async () => { |
| 302 | 301 | try { |
| 303 | − await doRemove({ path: { scopeId } }); | |
| 304 | − refreshConfig(); | |
| 302 | + await doRemove({ path: { scopeId }, reactivityKeys: [ReactivityKey.secrets] }); | |
| 305 | 303 | } catch { |
| 306 | 304 | /* TODO: toast */ |
| 307 | 305 | } |
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | import type { ScopeId } from "@executor/sdk"; |
| 2 | +import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | |
| 2 | 3 | import { OnePasswordClient } from "./client"; |
| 3 | 4 | |
| 5 | +export const onepasswordWriteKeys = [ReactivityKey.secrets] as const; | |
| 6 | + | |
| 4 | 7 | // --------------------------------------------------------------------------- |
| 5 | 8 | // Query atoms |
| 6 | 9 | // --------------------------------------------------------------------------- |
| @@ -9,12 +12,14 @@ export const onepasswordConfigAtom = (scopeId: ScopeId) => · 2 unmodified lines | ||
| 9 | 12 | OnePasswordClient.query("onepassword", "getConfig", { |
| 10 | 13 | path: { scopeId }, |
| 11 | 14 | timeToLive: "30 seconds", |
| 15 | + reactivityKeys: [ReactivityKey.secrets], | |
| 12 | 16 | }); |
| 13 | 17 | |
| 14 | 18 | export const onepasswordStatusAtom = (scopeId: ScopeId) => |
| 15 | 19 | OnePasswordClient.query("onepassword", "status", { |
| 16 | 20 | path: { scopeId }, |
| 17 | 21 | timeToLive: "15 seconds", |
| 22 | + reactivityKeys: [ReactivityKey.secrets], | |
| 18 | 23 | }); |
| 19 | 24 | |
| 20 | 25 | // --------------------------------------------------------------------------- |
| @@ -30,6 +35,7 @@ export const onepasswordVaultsAtom = ( · 9 unmodified lines | ||
| 30 | 35 | path: { scopeId }, |
| 31 | 36 | urlParams: { authKind, account }, |
| 32 | 37 | timeToLive: "30 seconds", |
| 38 | + reactivityKeys: [ReactivityKey.secrets], | |
| 33 | 39 | }); |
| 34 | 40 | |
| 35 | 41 | // --------------------------------------------------------------------------- |
| @@ -6,6 +6,8 @@ import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/r · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | import { SecretPicker } from "@executor/react/plugins/secret-picker"; |
| 8 | 8 | import { useScope } from "@executor/react/api/scope-context"; |
| 9 | +import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | |
| 10 | +import { usePendingSources } from "@executor/react/api/optimistic"; | |
| 9 | 11 | import { HeadersList } from "@executor/react/plugins/headers-list"; |
| 10 | 12 | import { |
| 11 | 13 | matchPresetKey, |
| @@ -171,6 +173,7 @@ export default function AddOpenApiSource(props: { · 159 unmodified lines | ||
| 171 | 173 | const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" }); |
| 172 | 174 | const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); |
| 173 | 175 | const doStartOAuth = useAtomSet(startOpenApiOAuth, { mode: "promise" }); |
| 176 | + const { beginAdd } = usePendingSources(); | |
| 174 | 177 | const secretList = useSecretPickerSecrets(); |
| 175 | 178 | |
| 176 | 179 | // Keep the latest handleAnalyze in a ref so the debounced effect doesn't |
| @@ -456,6 +459,18 @@ export default function AddOpenApiSource(props: { · 279 unmodified lines | ||
| 456 | 459 | const handleAdd = async () => { |
| 457 | 460 | setAdding(true); |
| 458 | 461 | setAddError(null); |
| 462 | + const namespace = | |
| 463 | + slugifyNamespace(identity.namespace) || | |
| 464 | + (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); | |
| 465 | + const displayName = | |
| 466 | + identity.name.trim() || | |
| 467 | + (preview ? Option.getOrElse(preview.title, () => namespace) : namespace); | |
| 468 | + const placeholder = beginAdd({ | |
| 469 | + id: namespace, | |
| 470 | + name: displayName, | |
| 471 | + kind: "openapi", | |
| 472 | + url: resolvedBaseUrl || undefined, | |
| 473 | + }); | |
| 459 | 474 | try { |
| 460 | 475 | await doAdd({ |
| 461 | 476 | path: { scopeId }, |
| @@ -467,11 +482,14 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines | ||
| 467 | 482 | ...(hasHeaders ? { headers: allHeaders } : {}), |
| 468 | 483 | ...(oauth2Auth ? { oauth2: oauth2Auth } : {}), |
| 469 | 484 | }, |
| 485 | + reactivityKeys: sourceWriteKeys, | |
| 470 | 486 | }); |
| 471 | 487 | props.onComplete(); |
| 472 | 488 | } catch (e) { |
| 473 | 489 | setAddError(e instanceof Error ? e.message : "Failed to add source"); |
| 474 | 490 | setAdding(false); |
| 491 | + } finally { | |
| 492 | + placeholder.done(); | |
| 475 | 493 | } |
| 476 | 494 | }; |
| 477 | 495 | |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { useState } from "react"; |
| 2 | −import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; | |
| 2 | +import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; | |
| 3 | 3 | import { openApiSourceAtom, updateOpenApiSource } from "./atoms"; |
| 4 | 4 | import { useScope } from "@executor/react/api/scope-context"; |
| 5 | +import { sourceWriteKeys } from "@executor/react/api/reactivity-keys"; | |
| 5 | 6 | import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; |
| 6 | 7 | import { |
| 7 | 8 | headerValueToState, |
| @@ -35,7 +36,6 @@ function EditForm(props: { · 27 unmodified lines | ||
| 35 | 36 | }) { |
| 36 | 37 | const scopeId = useScope(); |
| 37 | 38 | const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promise" }); |
| 38 | − const refreshSource = useAtomRefresh(openApiSourceAtom(scopeId, props.sourceId)); | |
| 39 | 39 | const secretList = useSecretPickerSecrets(); |
| 40 | 40 | |
| 41 | 41 | const identity = useSourceIdentity({ |
| @@ -70,8 +70,8 @@ function EditForm(props: { · 28 unmodified lines | ||
| 70 | 70 | baseUrl: baseUrl.trim() || undefined, |
| 71 | 71 | headers: headersFromState(headers), |
| 72 | 72 | }, |
| 73 | + reactivityKeys: sourceWriteKeys, | |
| 73 | 74 | }); |
| 74 | − refreshSource(); | |
| 75 | 75 | setDirty(false); |
| 76 | 76 | props.onSave(); |
| 77 | 77 | } catch (e) { |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { ScopeId } from "@executor/sdk"; |
| 2 | +import { ReactivityKey } from "@executor/react/api/reactivity-keys"; | |
| 2 | 3 | import { OpenApiClient } from "./client"; |
| 3 | 4 | |
| 4 | 5 | // --------------------------------------------------------------------------- |
| @@ -9,6 +10,7 @@ export const openApiSourceAtom = (scopeId: ScopeId, namespace: string) => · 4 unmodified lines | ||
| 9 | 10 | OpenApiClient.query("openapi", "getSource", { |
| 10 | 11 | path: { scopeId, namespace }, |
| 11 | 12 | timeToLive: "15 seconds", |
| 13 | + reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools], | |
| 12 | 14 | }); |
| 13 | 15 | |
| 14 | 16 | // --------------------------------------------------------------------------- |
| @@ -2,6 +2,7 @@ import type { ScopeId, ToolId, SecretId } from "@executor/sdk"; · 1 unmodified line | ||
| 2 | 2 | import { Atom } from "@effect-atom/atom-react"; |
| 3 | 3 | |
| 4 | 4 | import { ExecutorApiClient } from "./client"; |
| 5 | +import { ReactivityKey } from "./reactivity-keys"; | |
| 5 | 6 | |
| 6 | 7 | // --------------------------------------------------------------------------- |
| 7 | 8 | // Scope — fetched from the server |
| @@ -9,6 +10,7 @@ import { ExecutorApiClient } from "./client"; · 1 unmodified line | ||
| 9 | 10 | |
| 10 | 11 | export const scopeAtom = ExecutorApiClient.query("scope", "info", { |
| 11 | 12 | timeToLive: "5 minutes", |
| 13 | + reactivityKeys: [ReactivityKey.scope], | |
| 12 | 14 | }); |
| 13 | 15 | |
| 14 | 16 | // --------------------------------------------------------------------------- |
| @@ -19,6 +21,7 @@ export const toolsAtom = (scopeId: ScopeId) => · 4 unmodified lines | ||
| 19 | 21 | ExecutorApiClient.query("tools", "list", { |
| 20 | 22 | path: { scopeId }, |
| 21 | 23 | timeToLive: "30 seconds", |
| 24 | + reactivityKeys: [ReactivityKey.tools], | |
| 22 | 25 | }); |
| 23 | 26 | |
| 24 | 27 | /** Tools for a specific source */ |
| @@ -26,18 +29,21 @@ export const sourceToolsAtom = (sourceId: string, scopeId: ScopeId) => · 1 unmodified line | ||
| 26 | 29 | ExecutorApiClient.query("sources", "tools", { |
| 27 | 30 | path: { scopeId, sourceId }, |
| 28 | 31 | timeToLive: "30 seconds", |
| 32 | + reactivityKeys: [ReactivityKey.tools], | |
| 29 | 33 | }); |
| 30 | 34 | |
| 31 | 35 | export const toolSchemaAtom = (scopeId: ScopeId, toolId: ToolId) => |
| 32 | 36 | ExecutorApiClient.query("tools", "schema", { |
| 33 | 37 | path: { scopeId, toolId }, |
| 34 | 38 | timeToLive: "1 minute", |
| 39 | + reactivityKeys: [ReactivityKey.tools], | |
| 35 | 40 | }); |
| 36 | 41 | |
| 37 | 42 | export const sourcesAtom = (scopeId: ScopeId) => |
| 38 | 43 | ExecutorApiClient.query("sources", "list", { |
| 39 | 44 | path: { scopeId }, |
| 40 | 45 | timeToLive: "30 seconds", |
| 46 | + reactivityKeys: [ReactivityKey.sources], | |
| 41 | 47 | }); |
| 42 | 48 | |
| 43 | 49 | /** Single source by id — derived from the sources list */ |
| @@ -48,16 +54,20 @@ export const secretsAtom = (scopeId: ScopeId) => · 4 unmodified lines | ||
| 48 | 54 | ExecutorApiClient.query("secrets", "list", { |
| 49 | 55 | path: { scopeId }, |
| 50 | 56 | timeToLive: "30 seconds", |
| 57 | + reactivityKeys: [ReactivityKey.secrets], | |
| 51 | 58 | }); |
| 52 | 59 | |
| 53 | 60 | export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) => |
| 54 | 61 | ExecutorApiClient.query("secrets", "status", { |
| 55 | 62 | path: { scopeId, secretId }, |
| 56 | 63 | timeToLive: "15 seconds", |
| 64 | + reactivityKeys: [ReactivityKey.secrets], | |
| 57 | 65 | }); |
| 58 | 66 | |
| 59 | 67 | // --------------------------------------------------------------------------- |
| 60 | −// Mutation atoms | |
| 68 | +// Mutation atoms — reactivityKeys must be passed at call site (effect-atom | |
| 69 | +// does not accept them at definition time). See `reactivity-keys.tsx` for the | |
| 70 | +// canonical key arrays. | |
| 61 | 71 | // --------------------------------------------------------------------------- |
| 62 | 72 | |
| 63 | 73 | export const setSecret = ExecutorApiClient.mutation("secrets", "set"); |
| @@ -0,0 +1,143 @@ | ||
| 1 | +import * as React from "react"; | |
| 2 | +import { Atom, Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react"; | |
| 3 | +import type { ScopeId } from "@executor/sdk"; | |
| 4 | + | |
| 5 | +import { sourcesAtom } from "./atoms"; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * Client-only optimistic-update layer. | |
| 9 | + * | |
| 10 | + * Mutations that add a row return only after the server roundtrip. The | |
| 11 | + * `pendingResourceAtom` family lets a UI insert a placeholder *before* the | |
| 12 | + * mutation resolves so the sidebar/list updates instantly. Once the server | |
| 13 | + * returns and reactivity refetches the canonical list, callers should remove | |
| 14 | + * the placeholder (the dedupe in `mergePending` filters it anyway, but | |
| 15 | + * explicit removal keeps the pending set bounded on errors). | |
| 16 | + * | |
| 17 | + * Resource keys (`"sources"`, `"secrets"`, …) match the strings in | |
| 18 | + * `ReactivityKey` but are intentionally not coupled — pending state is purely | |
| 19 | + * client-side. | |
| 20 | + */ | |
| 21 | + | |
| 22 | +export const PendingResource = { | |
| 23 | + sources: "sources", | |
| 24 | +} as const; | |
| 25 | + | |
| 26 | +export interface PendingEntry<T> { | |
| 27 | + readonly id: string; | |
| 28 | + readonly value: T; | |
| 29 | +} | |
| 30 | + | |
| 31 | +const pendingFamily = Atom.family((_resource: string) => | |
| 32 | + Atom.make<ReadonlyArray<PendingEntry<unknown>>>([]), | |
| 33 | +); | |
| 34 | + | |
| 35 | +const atomFor = <T,>(resource: string) => | |
| 36 | + pendingFamily(resource) as ReturnType<typeof pendingFamily> & { | |
| 37 | + readonly __t?: T; | |
| 38 | + }; | |
| 39 | + | |
| 40 | +export const usePendingResource = <T,>(resource: string) => { | |
| 41 | + const atom = atomFor<T>(resource); | |
| 42 | + const pending = useAtomValue(atom) as ReadonlyArray<PendingEntry<T>>; | |
| 43 | + const setPending = useAtomSet(atom); | |
| 44 | + | |
| 45 | + const add = React.useCallback( | |
| 46 | + (entry: PendingEntry<T>) => | |
| 47 | + setPending((prev) => [...(prev as ReadonlyArray<PendingEntry<T>>), entry]), | |
| 48 | + [setPending], | |
| 49 | + ); | |
| 50 | + | |
| 51 | + const remove = React.useCallback( | |
| 52 | + (id: string) => | |
| 53 | + setPending((prev) => | |
| 54 | + (prev as ReadonlyArray<PendingEntry<T>>).filter((p) => p.id !== id), | |
| 55 | + ), | |
| 56 | + [setPending], | |
| 57 | + ); | |
| 58 | + | |
| 59 | + const clear = React.useCallback(() => setPending([]), [setPending]); | |
| 60 | + | |
| 61 | + return { pending, add, remove, clear }; | |
| 62 | +}; | |
| 63 | + | |
| 64 | +/** | |
| 65 | + * Merges pending entries with a server-loaded list. Pending entries whose id | |
| 66 | + * already exists on the server are dropped so we never double-render a row | |
| 67 | + * once the canonical version arrives. | |
| 68 | + */ | |
| 69 | +export const mergePending = <T, R>( | |
| 70 | + pending: ReadonlyArray<PendingEntry<T>>, | |
| 71 | + server: ReadonlyArray<R>, | |
| 72 | + serverId: (row: R) => string, | |
| 73 | + fromPending: (entry: PendingEntry<T>) => R, | |
| 74 | +): ReadonlyArray<R> => { | |
| 75 | + if (pending.length === 0) return server; | |
| 76 | + const seen = new Set(server.map(serverId)); | |
| 77 | + const extras = pending.filter((p) => !seen.has(p.id)).map(fromPending); | |
| 78 | + if (extras.length === 0) return server; | |
| 79 | + return [...extras, ...server]; | |
| 80 | +}; | |
| 81 | + | |
| 82 | +// --------------------------------------------------------------------------- | |
| 83 | +// Sources — convenience wrappers used by sidebar/list views and add forms. | |
| 84 | +// Pending entries are global (not per-scope) because the user only sees one | |
| 85 | +// scope at a time; this keeps the add-form code free of scope plumbing. | |
| 86 | +// --------------------------------------------------------------------------- | |
| 87 | + | |
| 88 | +export interface PendingSource { | |
| 89 | + readonly name: string; | |
| 90 | + readonly kind: string; | |
| 91 | + readonly url?: string; | |
| 92 | +} | |
| 93 | + | |
| 94 | +/** | |
| 95 | + * Sidebar/list helper. Reads `sourcesAtom(scopeId)` and merges any pending | |
| 96 | + * placeholder rows on top so the UI updates instantly when an add form pushes | |
| 97 | + * a placeholder. | |
| 98 | + */ | |
| 99 | +export const useSourcesWithPending = (scopeId: ScopeId) => { | |
| 100 | + const result = useAtomValue(sourcesAtom(scopeId)); | |
| 101 | + const { pending } = usePendingResource<PendingSource>(PendingResource.sources); | |
| 102 | + return React.useMemo( | |
| 103 | + () => | |
| 104 | + Result.map(result, (sources) => | |
| 105 | + mergePending( | |
| 106 | + pending, | |
| 107 | + sources, | |
| 108 | + (s) => s.id, | |
| 109 | + (p) => ({ | |
| 110 | + id: p.id, | |
| 111 | + name: p.value.name, | |
| 112 | + kind: p.value.kind, | |
| 113 | + url: p.value.url, | |
| 114 | + // The placeholder cannot be removed/refreshed/edited until the | |
| 115 | + // server confirms it, so disable those affordances. | |
| 116 | + canRemove: false, | |
| 117 | + canRefresh: false, | |
| 118 | + canEdit: false, | |
| 119 | + }), | |
| 120 | + ), | |
| 121 | + ), | |
| 122 | + [result, pending], | |
| 123 | + ); | |
| 124 | +}; | |
| 125 | + | |
| 126 | +/** | |
| 127 | + * Hook for an add form. Returns helpers to push and clear a pending source | |
| 128 | + * placeholder. On submit: | |
| 129 | + * const placeholder = beginAdd({ id: namespace, name, kind }); | |
| 130 | + * try { await doAdd(...); } finally { placeholder.done(); } | |
| 131 | + */ | |
| 132 | +export const usePendingSources = () => { | |
| 133 | + const { add, remove } = usePendingResource<PendingSource>(PendingResource.sources); | |
| 134 | + return React.useMemo( | |
| 135 | + () => ({ | |
| 136 | + beginAdd: (entry: { id: string } & PendingSource) => { | |
| 137 | + add({ id: entry.id, value: { name: entry.name, kind: entry.kind, url: entry.url } }); | |
| 138 | + return { done: () => remove(entry.id) }; | |
| 139 | + }, | |
| 140 | + }), | |
| 141 | + [add, remove], | |
| 142 | + ); | |
| 143 | +}; | |
| @@ -0,0 +1,57 @@ | ||
| 1 | +/** | |
| 2 | + * Canonical reactivity keys for query/mutation invalidation. | |
| 3 | + * | |
| 4 | + * effect-atom's `Reactivity` service refreshes any query whose `reactivityKeys` | |
| 5 | + * overlap with a completed mutation's `reactivityKeys`. The Reactivity instance | |
| 6 | + * is shared across the global Atom registry, so keys interop across plugin | |
| 7 | + * clients (`McpClient`, `OpenApiClient`, `ExecutorApiClient`, …). | |
| 8 | + * | |
| 9 | + * Conventions: | |
| 10 | + * - Every query that reads a server resource sets `reactivityKeys` at the | |
| 11 | + * query atom's definition site. | |
| 12 | + * - Every mutation passes `reactivityKeys` at the call site (mutations don't | |
| 13 | + * accept the option at definition time — see effect-atom AtomHttpApi). | |
| 14 | + * - Use the constants below; do not invent ad-hoc string keys at call sites. | |
| 15 | + * | |
| 16 | + * Per-scope precision is intentionally dropped: a mutation in one scope | |
| 17 | + * invalidating another scope's queries is harmless (users see one scope at a | |
| 18 | + * time) and keeps the convention ergonomic. | |
| 19 | + */ | |
| 20 | +export const ReactivityKey = { | |
| 21 | + sources: "sources", | |
| 22 | + tools: "tools", | |
| 23 | + secrets: "secrets", | |
| 24 | + scope: "scope", | |
| 25 | + // cloud-only resources | |
| 26 | + orgMembers: "org:members", | |
| 27 | + orgDomains: "org:domains", | |
| 28 | + orgInfo: "org:info", | |
| 29 | + auth: "auth", | |
| 30 | +} as const; | |
| 31 | + | |
| 32 | +/** Mutations that add/remove/refresh a source also affect tool listings. */ | |
| 33 | +export const sourceWriteKeys = [ReactivityKey.sources, ReactivityKey.tools] as const; | |
| 34 | + | |
| 35 | +/** Mutations that mint or revoke secrets. */ | |
| 36 | +export const secretWriteKeys = [ReactivityKey.secrets] as const; | |
| 37 | + | |
| 38 | +/** Mutations that change scope membership/info. */ | |
| 39 | +export const scopeWriteKeys = [ReactivityKey.scope] as const; | |
| 40 | + | |
| 41 | +/** Cloud-only: org membership mutations. */ | |
| 42 | +export const orgMemberWriteKeys = [ReactivityKey.orgMembers] as const; | |
| 43 | + | |
| 44 | +/** Cloud-only: org domain mutations. */ | |
| 45 | +export const orgDomainWriteKeys = [ReactivityKey.orgDomains] as const; | |
| 46 | + | |
| 47 | +/** Cloud-only: org info mutations (name, etc.) — also touches scope/auth. */ | |
| 48 | +export const orgInfoWriteKeys = [ReactivityKey.orgInfo, ReactivityKey.auth] as const; | |
| 49 | + | |
| 50 | +/** Cloud-only: auth mutations (org switch/create) — invalidate everything user-visible. */ | |
| 51 | +export const authWriteKeys = [ | |
| 52 | + ReactivityKey.auth, | |
| 53 | + ReactivityKey.orgInfo, | |
| 54 | + ReactivityKey.orgMembers, | |
| 55 | + ReactivityKey.orgDomains, | |
| 56 | + ReactivityKey.scope, | |
| 57 | +] as const; | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { useState, Suspense } from "react"; |
| 2 | −import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react"; | |
| 2 | +import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react"; | |
| 3 | 3 | import { secretsAtom, setSecret, removeSecret } from "../api/atoms"; |
| 4 | +import { secretWriteKeys } from "../api/reactivity-keys"; | |
| 4 | 5 | import type { SecretProviderPlugin } from "../plugins/secret-provider-plugin"; |
| 5 | 6 | import { SecretId } from "@executor/sdk"; |
| 6 | 7 | import { useScope } from "../hooks/use-scope"; |
| @@ -72,7 +73,6 @@ function AddSecretDialog(props: { · 65 unmodified lines | ||
| 72 | 73 | |
| 73 | 74 | const scopeId = useScope(); |
| 74 | 75 | const doSet = useAtomSet(setSecret, { mode: "promise" }); |
| 75 | − const refresh = useAtomRefresh(secretsAtom(scopeId)); | |
| 76 | 76 | |
| 77 | 77 | const reset = () => { |
| 78 | 78 | setId(""); |
| @@ -96,10 +96,10 @@ function AddSecretDialog(props: { · 17 unmodified lines | ||
| 96 | 96 | value: value.trim(), |
| 97 | 97 | provider: provider === "auto" ? undefined : provider, |
| 98 | 98 | }, |
| 99 | + reactivityKeys: secretWriteKeys, | |
| 99 | 100 | }); |
| 100 | 101 | reset(); |
| 101 | 102 | props.onOpenChange(false); |
| 102 | − refresh(); | |
| 103 | 103 | } catch (e) { |
| 104 | 104 | setError(e instanceof Error ? e.message : "Failed to save secret"); |
| 105 | 105 | setSaving(false); |
| @@ -292,7 +292,6 @@ export function SecretsPage(props: { · 186 unmodified lines | ||
| 292 | 292 | const scopeId = useScope(); |
| 293 | 293 | const secrets = useAtomValue(secretsAtom(scopeId)); |
| 294 | 294 | const doRemove = useAtomSet(removeSecret, { mode: "promise" }); |
| 295 | − const refresh = useAtomRefresh(secretsAtom(scopeId)); | |
| 296 | 295 | |
| 297 | 296 | const handleRemove = async (secretId: string) => { |
| 298 | 297 | try { |
| @@ -301,8 +300,8 @@ export function SecretsPage(props: { · 2 unmodified lines | ||
| 301 | 300 | scopeId, |
| 302 | 301 | secretId: SecretId.make(secretId), |
| 303 | 302 | }, |
| 303 | + reactivityKeys: secretWriteKeys, | |
| 304 | 304 | }); |
| 305 | − refresh(); | |
| 306 | 305 | } catch { |
| 307 | 306 | // TODO: toast |
| 308 | 307 | } |
| @@ -8,6 +8,7 @@ import { · 7 unmodified lines | ||
| 8 | 8 | removeSource, |
| 9 | 9 | refreshSource, |
| 10 | 10 | } from "../api/atoms"; |
| 11 | +import { sourceWriteKeys } from "../api/reactivity-keys"; | |
| 11 | 12 | import { ToolTree } from "../components/tool-tree"; |
| 12 | 13 | import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail"; |
| 13 | 14 | import type { ToolSummary } from "../components/tool-tree"; |
| @@ -81,8 +82,8 @@ export function SourceDetailPage(props: { · 67 unmodified lines | ||
| 81 | 82 | try { |
| 82 | 83 | await doRemove({ |
| 83 | 84 | path: { scopeId, sourceId: namespace }, |
| 85 | + reactivityKeys: sourceWriteKeys, | |
| 84 | 86 | }); |
| 85 | − refreshSources(); | |
| 86 | 87 | void navigate({ to: "/" }); |
| 87 | 88 | } catch { |
| 88 | 89 | setDeleting(false); |
| @@ -95,9 +96,8 @@ export function SourceDetailPage(props: { · 6 unmodified lines | ||
| 95 | 96 | try { |
| 96 | 97 | await doRefresh({ |
| 97 | 98 | path: { scopeId, sourceId: namespace }, |
| 99 | + reactivityKeys: sourceWriteKeys, | |
| 98 | 100 | }); |
| 99 | − refreshTools(); | |
| 100 | − refreshSources(); | |
| 101 | 101 | } finally { |
| 102 | 102 | setRefreshing(false); |
| 103 | 103 | } |
| @@ -105,8 +105,6 @@ export function SourceDetailPage(props: { · 1 unmodified line | ||
| 105 | 105 | |
| 106 | 106 | const handleEditSave = () => { |
| 107 | 107 | setEditing(false); |
| 108 | − refreshSources(); | |
| 109 | − refreshTools(); | |
| 110 | 108 | }; |
| 111 | 109 | |
| 112 | 110 | return ( |
| @@ -1,9 +1,6 @@ | ||
| 1 | 1 | import { Suspense } from "react"; |
| 2 | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | 3 | import type { SourcePlugin } from "../plugins/source-plugin"; |
| 4 | −import { useAtomRefresh } from "@effect-atom/atom-react"; | |
| 5 | −import { sourcesAtom } from "../api/atoms"; | |
| 6 | −import { useScope } from "../hooks/use-scope"; | |
| 7 | 4 | |
| 8 | 5 | // --------------------------------------------------------------------------- |
| 9 | 6 | // Page |
| @@ -17,8 +14,6 @@ export function SourcesAddPage(props: { · 7 unmodified lines | ||
| 17 | 14 | sourcePlugins: readonly SourcePlugin[]; |
| 18 | 15 | }) { |
| 19 | 16 | const { pluginKey, url, preset, namespace, sourcePlugins } = props; |
| 20 | − const scopeId = useScope(); | |
| 21 | − const refreshSources = useAtomRefresh(sourcesAtom(scopeId)); | |
| 22 | 17 | const navigate = useNavigate(); |
| 23 | 18 | |
| 24 | 19 | const plugin = sourcePlugins.find((p) => p.key === pluginKey); |
| @@ -57,7 +52,6 @@ export function SourcesAddPage(props: { · 32 unmodified lines | ||
| 57 | 52 | initialPreset={preset} |
| 58 | 53 | initialNamespace={namespace} |
| 59 | 54 | onComplete={() => { |
| 60 | − refreshSources(); | |
| 61 | 55 | void navigate({ to: "/" }); |
| 62 | 56 | }} |
| 63 | 57 | onCancel={() => { |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { useState, useCallback, useMemo } from "react"; |
| 2 | 2 | import { Link, useNavigate } from "@tanstack/react-router"; |
| 3 | −import { Result, useAtomValue, useAtomSet } from "@effect-atom/atom-react"; | |
| 4 | −import { sourcesAtom, detectSource } from "../api/atoms"; | |
| 3 | +import { Result, useAtomSet } from "@effect-atom/atom-react"; | |
| 4 | +import { detectSource } from "../api/atoms"; | |
| 5 | +import { useSourcesWithPending } from "../api/optimistic"; | |
| 5 | 6 | import { useScope } from "../hooks/use-scope"; |
| 6 | 7 | import type { SourcePlugin, SourcePreset } from "../plugins/source-plugin"; |
| 7 | 8 | import { McpInstallCard } from "../components/mcp-install-card"; |
| @@ -41,7 +42,7 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) { · 33 unmodified lines | ||
| 41 | 42 | const [error, setError] = useState<string | null>(null); |
| 42 | 43 | |
| 43 | 44 | const scopeId = useScope(); |
| 44 | − const sources = useAtomValue(sourcesAtom(scopeId)); | |
| 45 | + const sources = useSourcesWithPending(scopeId); | |
| 45 | 46 | const doDetect = useAtomSet(detectSource, { mode: "promise" }); |
| 46 | 47 | const navigate = useNavigate(); |
| 47 | 48 | |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { useId, useState } from "react"; |
| 2 | −import { useAtomRefresh, useAtomSet } from "@effect-atom/atom-react"; | |
| 2 | +import { useAtomSet } from "@effect-atom/atom-react"; | |
| 3 | 3 | |
| 4 | −import { secretsAtom, setSecret, resolveSecret } from "../api/atoms"; | |
| 4 | +import { setSecret, resolveSecret } from "../api/atoms"; | |
| 5 | +import { secretWriteKeys } from "../api/reactivity-keys"; | |
| 5 | 6 | import { useScope } from "../api/scope-context"; |
| 6 | 7 | import { Button } from "../components/button"; |
| 7 | 8 | import { Field, FieldError, FieldGroup, FieldLabel } from "../components/field"; |
| @@ -73,7 +74,6 @@ function InlineCreateSecret(props: { · 65 unmodified lines | ||
| 73 | 74 | const [error, setError] = useState<string | null>(null); |
| 74 | 75 | const scopeId = useScope(); |
| 75 | 76 | const doSet = useAtomSet(setSecret, { mode: "promise" }); |
| 76 | − const refreshSecrets = useAtomRefresh(secretsAtom(scopeId)); | |
| 77 | 77 | const secretIdInputId = useId(); |
| 78 | 78 | const secretNameInputId = useId(); |
| 79 | 79 | const secretValueInputId = useId(); |
| @@ -90,8 +90,8 @@ function InlineCreateSecret(props: { · 10 unmodified lines | ||
| 90 | 90 | name: `${secretName.trim() || secretId.trim()} (Auth header: ${props.headerName})`, |
| 91 | 91 | value: secretValue.trim(), |
| 92 | 92 | }, |
| 93 | + reactivityKeys: secretWriteKeys, | |
| 93 | 94 | }); |
| 94 | − refreshSecrets(); | |
| 95 | 95 | props.onCreated(secretId.trim()); |
| 96 | 96 | } catch (e) { |
| 97 | 97 | setError(e instanceof Error ? e.message : "Failed to save secret"); |
| @@ -0,0 +1,124 @@ | ||
| 1 | +#!/usr/bin/env bun | |
| 2 | +// Fail CI when a frontend file calls a mutation via `useAtomSet(<atom>, { mode: "promise" })` | |
| 3 | +// without ever passing `reactivityKeys` in the corresponding call args. | |
| 4 | +// | |
| 5 | +// The check is intentionally coarse: per-file we count `useAtomSet(...)` sites | |
| 6 | +// and `reactivityKeys` mentions. A file with N mutation sites must mention | |
| 7 | +// `reactivityKeys` at least N times. Read-only mutations (probes, previews, | |
| 8 | +// auth-flow starts) are allow-listed so the convention can be locked in | |
| 9 | +// without false positives. | |
| 10 | +// | |
| 11 | +// Run: `bun run scripts/check-reactivity-keys.ts` | |
| 12 | +// Exits 1 with a punch list when violations exist. | |
| 13 | + | |
| 14 | +import { Glob } from "bun"; | |
| 15 | + | |
| 16 | +const ROOTS = ["packages", "apps"]; | |
| 17 | + | |
| 18 | +// Mutations that intentionally do not invalidate any cached query (probes, | |
| 19 | +// previews, OAuth-flow kickoffs that just open a window). | |
| 20 | +const READ_ONLY_MUTATIONS = new Set<string>([ | |
| 21 | + "probeMcpEndpoint", | |
| 22 | + "startMcpOAuth", | |
| 23 | + "probeGoogleDiscovery", | |
| 24 | + "startGoogleDiscoveryOAuth", | |
| 25 | + "previewOpenApiSpec", | |
| 26 | + "startOpenApiOAuth", | |
| 27 | + "resolveSecret", | |
| 28 | + "detectSource", | |
| 29 | + "getDomainVerificationLink", | |
| 30 | +]); | |
| 31 | + | |
| 32 | +interface Violation { | |
| 33 | + readonly file: string; | |
| 34 | + readonly line: number; | |
| 35 | + readonly mutationVar: string; | |
| 36 | +} | |
| 37 | + | |
| 38 | +const violations: Violation[] = []; | |
| 39 | + | |
| 40 | +const useAtomSetRegex = /useAtomSet\(\s*([A-Za-z_][\w]*)\s*,\s*\{\s*mode:\s*"promise"\s*\}\s*\)/g; | |
| 41 | + | |
| 42 | +for (const root of ROOTS) { | |
| 43 | + const glob = new Glob(`${root}/**/*.{ts,tsx}`); | |
| 44 | + for await (const path of glob.scan({ cwd: import.meta.dir + "/.." })) { | |
| 45 | + if (path.includes("node_modules") || path.includes(".test.") || path.endsWith(".d.ts")) continue; | |
| 46 | + const file = Bun.file(`${import.meta.dir}/../${path}`); | |
| 47 | + const text = await file.text(); | |
| 48 | + if (!text.includes("useAtomSet")) continue; | |
| 49 | + | |
| 50 | + const lines = text.split("\n"); | |
| 51 | + let cursor = 0; | |
| 52 | + for (const line of lines) { | |
| 53 | + cursor++; | |
| 54 | + useAtomSetRegex.lastIndex = 0; | |
| 55 | + const match = useAtomSetRegex.exec(line); | |
| 56 | + if (!match) continue; | |
| 57 | + const mutationVar = match[1] ?? "<unknown>"; | |
| 58 | + // The variable being declared from useAtomSet is what the file later awaits. | |
| 59 | + // Find the binding name to look up the await call. | |
| 60 | + const bindingMatch = line.match(/const\s+(\w+)\s*=\s*useAtomSet/); | |
| 61 | + const binding = bindingMatch?.[1]; | |
| 62 | + | |
| 63 | + if (READ_ONLY_MUTATIONS.has(mutationVar)) continue; | |
| 64 | + if (!binding) continue; | |
| 65 | + | |
| 66 | + // Look for `await <binding>(...)` calls in the rest of the file and | |
| 67 | + // check whether the *args object* contains `reactivityKeys`. We scan | |
| 68 | + // line-by-line and grow a brace-aware window once we see the call. | |
| 69 | + const callRegex = new RegExp(`await\\s+${binding}\\s*\\(`); | |
| 70 | + let i = cursor; | |
| 71 | + while (i < lines.length) { | |
| 72 | + const l = lines[i] ?? ""; | |
| 73 | + if (callRegex.test(l)) { | |
| 74 | + // Extract the args block from the opening paren until balanced close. | |
| 75 | + let depth = 0; | |
| 76 | + let started = false; | |
| 77 | + let argText = ""; | |
| 78 | + let j = i; | |
| 79 | + while (j < lines.length) { | |
| 80 | + const cur = lines[j] ?? ""; | |
| 81 | + for (const ch of cur) { | |
| 82 | + if (ch === "(") { | |
| 83 | + depth++; | |
| 84 | + started = true; | |
| 85 | + continue; | |
| 86 | + } | |
| 87 | + if (started && ch === ")") { | |
| 88 | + depth--; | |
| 89 | + if (depth === 0) break; | |
| 90 | + } | |
| 91 | + if (started) argText += ch; | |
| 92 | + } | |
| 93 | + if (started && depth === 0) break; | |
| 94 | + j++; | |
| 95 | + if (j - i > 60) break; // bail on runaway | |
| 96 | + } | |
| 97 | + if (!argText.includes("reactivityKeys")) { | |
| 98 | + violations.push({ file: path, line: i + 1, mutationVar }); | |
| 99 | + } | |
| 100 | + break; | |
| 101 | + } | |
| 102 | + i++; | |
| 103 | + if (i - cursor > 80) break; // give up on this site if the await is far away | |
| 104 | + } | |
| 105 | + } | |
| 106 | + } | |
| 107 | +} | |
| 108 | + | |
| 109 | +if (violations.length === 0) { | |
| 110 | + console.log("✓ reactivityKeys check passed"); | |
| 111 | + process.exit(0); | |
| 112 | +} | |
| 113 | + | |
| 114 | +console.error(`✗ reactivityKeys check failed — ${violations.length} mutation call(s) missing reactivityKeys:\n`); | |
| 115 | +for (const v of violations) { | |
| 116 | + console.error(` ${v.file}:${v.line} — ${v.mutationVar}`); | |
| 117 | +} | |
| 118 | +console.error( | |
| 119 | + `\nEvery write mutation must pass reactivityKeys at the call site.\n` + | |
| 120 | + `See packages/react/src/api/reactivity-keys.tsx for canonical key arrays.\n` + | |
| 121 | + `If this mutation truly is read-only (probe/preview/OAuth-start), add it to\n` + | |
| 122 | + `the READ_ONLY_MUTATIONS allowlist in scripts/check-reactivity-keys.ts.`, | |
| 123 | +); | |
| 124 | +process.exit(1); | |