History

refactor(react): declarative reactivity keys + optimistic source add

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

Rhys Sullivan committed 7 months ago

Commit c9a91b91 parent 6e4fc7930 files · +544 −119Browse files

Unified · Split

30 files
  1. apps/cloud/src/routes/org.tsx +23 −18
  2. apps/cloud/src/web/auth.tsx +6 −1
  3. apps/cloud/src/web/components/create-organization-form.tsx +2 −1
  4. apps/cloud/src/web/org-atoms.ts +4 −0
  5. apps/cloud/src/web/pages/onboarding.tsx +5 −10
  6. apps/cloud/src/web/shell.tsx +2 −2
  7. apps/local/src/web/shell.tsx +3 −2
  8. package.json +2 −1
  9. packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +17 −5
  10. packages/plugins/google-discovery/src/react/atoms.ts +2 −0
  11. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +30 −11
  12. packages/plugins/graphql/src/react/EditGraphqlSource.tsx +3 −3
  13. packages/plugins/graphql/src/react/atoms.ts +2 −0
  14. packages/plugins/mcp/src/react/AddMcpSource.tsx +55 −29
  15. packages/plugins/mcp/src/react/EditMcpSource.tsx +3 −3
  16. packages/plugins/mcp/src/react/atoms.ts +2 −0
  17. packages/plugins/onepassword/src/react/OnePasswordSettings.tsx +4 −6
  18. packages/plugins/onepassword/src/react/atoms.ts +6 −0
  19. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +18 −0
  20. packages/plugins/openapi/src/react/EditOpenApiSource.tsx +3 −3
  21. packages/plugins/openapi/src/react/atoms.ts +2 −0
  22. packages/react/src/api/atoms.tsx +11 −1
  23. packages/react/src/api/optimistic.tsx +143 −0
  24. packages/react/src/api/reactivity-keys.tsx +57 −0
  25. packages/react/src/pages/secrets.tsx +4 −5
  26. packages/react/src/pages/source-detail.tsx +3 −5
  27. packages/react/src/pages/sources-add.tsx +0 −6
  28. packages/react/src/pages/sources.tsx +4 −3
  29. packages/react/src/plugins/secret-header-auth.tsx +4 −4
  30. scripts/check-reactivity-keys.ts +124 −0
apps/cloud/src/routes/org.tsx+23−18
@@ -1,9 +1,14 @@
1import { useReducer, useState } from "react";1import { useReducer, useState } from "react";
2import { Exit } from "effect";2import { Exit } from "effect";
3import { createFileRoute, Link } from "@tanstack/react-router";3import { createFileRoute, Link } from "@tanstack/react-router";
4import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";4import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
5import { useCustomer } from "autumn-js/react";5import { useCustomer } from "autumn-js/react";
6import { toast } from "sonner";6import { toast } from "sonner";
7import {
8 orgMemberWriteKeys,
9 orgDomainWriteKeys,
10 orgInfoWriteKeys,
11} from "@executor/react/api/reactivity-keys";
7import {12import {
8 Dialog,13 Dialog,
9 DialogContent,14 DialogContent,
@@ -46,7 +51,7 @@ import { · 36 unmodified lines
46 deleteDomain,51 deleteDomain,
47 updateOrgName,52 updateOrgName,
48} from "../web/org-atoms";53} from "../web/org-atoms";
49import { authAtom, useAuth } from "../web/auth";54import { useAuth } from "../web/auth";
50 55
51export const Route = createFileRoute("/org")({56export const Route = createFileRoute("/org")({
52 component: OrgPage,57 component: OrgPage,
@@ -109,8 +114,6 @@ function OrgPage() { · 56 unmodified lines
109 const membersResult = useAtomValue(orgMembersAtom);114 const membersResult = useAtomValue(orgMembersAtom);
110 const rolesResult = useAtomValue(orgRolesAtom);115 const rolesResult = useAtomValue(orgRolesAtom);
111 const domainsResult = useAtomValue(orgDomainsAtom);116 const domainsResult = useAtomValue(orgDomainsAtom);
112 const refreshMembers = useAtomRefresh(orgMembersAtom);
113 const refreshDomains = useAtomRefresh(orgDomainsAtom);
114 const doRemove = useAtomSet(removeMember, { mode: "promiseExit" });117 const doRemove = useAtomSet(removeMember, { mode: "promiseExit" });
115 const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" });118 const doUpdateRole = useAtomSet(updateMemberRole, { mode: "promiseExit" });
116 const doDeleteDomain = useAtomSet(deleteDomain, { mode: "promiseExit" });119 const doDeleteDomain = useAtomSet(deleteDomain, { mode: "promiseExit" });
@@ -130,27 +133,27 @@ function OrgPage() { · 13 unmodified lines
130 });133 });
131 134
132 const handleRemove = async (membershipId: string, name: string) => {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 if (Exit.isSuccess(exit)) {137 if (Exit.isSuccess(exit)) {
135 toast.success(`Removed ${name}`);138 toast.success(`Removed ${name}`);
136 refreshMembers();
137 } else {139 } else {
138 toast.error("Failed to remove member");140 toast.error("Failed to remove member");
139 }141 }
140 };142 };
141 143
142 const handleChangeRole = async (membershipId: string, roleSlug: string, roleName: string) => {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 if (Exit.isSuccess(exit)) {150 if (Exit.isSuccess(exit)) {
145 toast.success(`Role changed to ${roleName}`);151 toast.success(`Role changed to ${roleName}`);
146 refreshMembers();
147 } else {152 } else {
148 toast.error("Failed to change role");153 toast.error("Failed to change role");
149 }154 }
150 };155 };
151 156
152 const refreshAuth = useAtomRefresh(authAtom);
153
154 const handleSaveName = async () => {157 const handleSaveName = async () => {
155 const trimmed = editName.trim();158 const trimmed = editName.trim();
156 if (!trimmed || trimmed === orgName) {159 if (!trimmed || trimmed === orgName) {
@@ -158,10 +161,12 @@ function OrgPage() { · 1 unmodified line
158 return;161 return;
159 }162 }
160 setSavingName(true);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 if (Exit.isSuccess(exit)) {168 if (Exit.isSuccess(exit)) {
163 toast.success("Organization name updated");169 toast.success("Organization name updated");
164 refreshAuth();
165 } else {170 } else {
166 toast.error("Failed to update organization name");171 toast.error("Failed to update organization name");
167 setEditName(orgName);172 setEditName(orgName);
@@ -170,17 +175,19 @@ function OrgPage() { · 2 unmodified lines
170 };175 };
171 176
172 const handleDeleteDomain = async (domainId: string, domain: string) => {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 if (Exit.isSuccess(exit)) {182 if (Exit.isSuccess(exit)) {
175 toast.success(`Removed ${domain}`);183 toast.success(`Removed ${domain}`);
176 refreshDomains();
177 } else {184 } else {
178 toast.error("Failed to remove domain");185 toast.error("Failed to remove domain");
179 }186 }
180 };187 };
181 188
182 const handleAddDomain = async () => {189 const handleAddDomain = async () => {
183 const exit = await doGetVerificationLink({});190 const exit = await doGetVerificationLink({ reactivityKeys: orgDomainWriteKeys });
184 if (Exit.isSuccess(exit)) {191 if (Exit.isSuccess(exit)) {
185 window.open(exit.value.link, "_blank");192 window.open(exit.value.link, "_blank");
186 } else {193 } else {
@@ -473,7 +480,6 @@ function OrgPage() { · 286 unmodified lines
473 <InviteDialog480 <InviteDialog
474 open={inviteOpen}481 open={inviteOpen}
475 onOpenChange={setInviteOpen}482 onOpenChange={setInviteOpen}
476 onInvited={refreshMembers}
477 roles={roles}483 roles={roles}
478 />484 />
479 </div>485 </div>
@@ -580,7 +586,6 @@ function DomainCard({ · 100 unmodified lines
580function InviteDialog(props: {586function InviteDialog(props: {
581 open: boolean;587 open: boolean;
582 onOpenChange: (v: boolean) => void;588 onOpenChange: (v: boolean) => void;
583 onInvited: () => void;
584 roles: readonly { slug: string; name: string }[];589 roles: readonly { slug: string; name: string }[];
585}) {590}) {
586 const [state, dispatch] = useReducer(inviteReducer, initialInviteState);591 const [state, dispatch] = useReducer(inviteReducer, initialInviteState);
@@ -595,13 +600,13 @@ function InviteDialog(props: { · 8 unmodified lines
595 email: state.email.trim(),600 email: state.email.trim(),
596 ...(state.roleSlug ? { roleSlug: state.roleSlug } : {}),601 ...(state.roleSlug ? { roleSlug: state.roleSlug } : {}),
597 },602 },
603 reactivityKeys: orgMemberWriteKeys,
598 });604 });
599 605
600 if (Exit.isSuccess(exit)) {606 if (Exit.isSuccess(exit)) {
601 toast.success(`Invitation sent to ${state.email.trim()}`);607 toast.success(`Invitation sent to ${state.email.trim()}`);
602 dispatch({ type: "reset" });608 dispatch({ type: "reset" });
603 props.onOpenChange(false);609 props.onOpenChange(false);
604 props.onInvited();
605 } else {610 } else {
606 dispatch({ type: "error", message: "Failed to send invitation" });611 dispatch({ type: "error", message: "Failed to send invitation" });
607 }612 }
apps/cloud/src/web/auth.tsx+6−1
@@ -1,6 +1,7 @@
1import React, { createContext, useContext } from "react";1import React, { createContext, useContext } from "react";
2import { Atom } from "@effect-atom/atom";2import { Atom } from "@effect-atom/atom";
3import { useAtomValue, Result } from "@effect-atom/atom-react";3import { useAtomValue, Result } from "@effect-atom/atom-react";
4import { ReactivityKey } from "@executor/react/api/reactivity-keys";
4 5
5import { CloudApiClient } from "./client";6import { CloudApiClient } from "./client";
6 7
@@ -26,10 +27,14 @@ type AuthOrganization = { · 19 unmodified lines
26 27
27export const authAtom = CloudApiClient.query("cloudAuth", "me", {28export const authAtom = CloudApiClient.query("cloudAuth", "me", {
28 timeToLive: "5 minutes",29 timeToLive: "5 minutes",
30 reactivityKeys: [ReactivityKey.auth],
29});31});
30 32
31export const organizationsAtom = Atom.refreshOnWindowFocus(33export 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
35export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization");40export const switchOrganization = CloudApiClient.mutation("cloudAuth", "switchOrganization");
apps/cloud/src/web/components/create-organization-form.tsx+2−1
@@ -1,5 +1,6 @@
1import { useState } from "react";1import { useState } from "react";
2import { useAtomSet } from "@effect-atom/atom-react";2import { useAtomSet } from "@effect-atom/atom-react";
3import { authWriteKeys } from "@executor/react/api/reactivity-keys";
3import { Input } from "@executor/react/components/input";4import { Input } from "@executor/react/components/input";
4import { Label } from "@executor/react/components/label";5import { Label } from "@executor/react/components/label";
5 6
@@ -31,7 +32,7 @@ export function useCreateOrganizationForm(options: { · 25 unmodified lines
31 }32 }
32 setCreating(true);33 setCreating(true);
33 setError(null);34 setError(null);
34 const exit = await doCreate({ payload: { name: trimmed } });35 const exit = await doCreate({ payload: { name: trimmed }, reactivityKeys: authWriteKeys });
35 setCreating(false);36 setCreating(false);
36 if (exit._tag === "Success") {37 if (exit._tag === "Success") {
37 options.onSuccess(exit.value);38 options.onSuccess(exit.value);
apps/cloud/src/web/org-atoms.ts+4−0
@@ -1,14 +1,17 @@
1import { Atom } from "@effect-atom/atom";1import { Atom } from "@effect-atom/atom";
2import { ReactivityKey } from "@executor/react/api/reactivity-keys";
2import { CloudApiClient } from "./client";3import { CloudApiClient } from "./client";
3 4
4export const orgMembersAtom = Atom.refreshOnWindowFocus(5export const orgMembersAtom = Atom.refreshOnWindowFocus(
5 CloudApiClient.query("org", "listMembers", {6 CloudApiClient.query("org", "listMembers", {
6 timeToLive: "30 seconds",7 timeToLive: "30 seconds",
8 reactivityKeys: [ReactivityKey.orgMembers],
7 }),9 }),
8);10);
9 11
10export const orgRolesAtom = CloudApiClient.query("org", "listRoles", {12export const orgRolesAtom = CloudApiClient.query("org", "listRoles", {
11 timeToLive: "5 minutes",13 timeToLive: "5 minutes",
14 reactivityKeys: [ReactivityKey.orgMembers],
12});15});
13 16
14export const inviteMember = CloudApiClient.mutation("org", "invite");17export const inviteMember = CloudApiClient.mutation("org", "invite");
@@ -20,6 +23,7 @@ export const updateMemberRole = CloudApiClient.mutation("org", "updateMemberRole · 5 unmodified lines
20export const orgDomainsAtom = Atom.refreshOnWindowFocus(23export const orgDomainsAtom = Atom.refreshOnWindowFocus(
21 CloudApiClient.query("org", "listDomains", {24 CloudApiClient.query("org", "listDomains", {
22 timeToLive: "30 seconds",25 timeToLive: "30 seconds",
26 reactivityKeys: [ReactivityKey.orgDomains],
23 }),27 }),
24);28);
25 29
apps/cloud/src/web/pages/onboarding.tsx+5−10
@@ -1,8 +1,7 @@
1import { useAtomRefresh } from "@effect-atom/atom-react";
2import { Button } from "@executor/react/components/button";1import { Button } from "@executor/react/components/button";
3 2
4import { AUTH_PATHS } from "../../auth/api";3import { AUTH_PATHS } from "../../auth/api";
5import { authAtom, useAuth } from "../auth";4import { useAuth } from "../auth";
6import {5import {
7 CreateOrganizationFields,6 CreateOrganizationFields,
8 useCreateOrganizationForm,7 useCreateOrganizationForm,
@@ -10,7 +9,6 @@ import { · 1 unmodified line
10 9
11export const OnboardingPage = () => {10export const OnboardingPage = () => {
12 const auth = useAuth();11 const auth = useAuth();
13 const refreshAuth = useAtomRefresh(authAtom);
14 12
15 const suggestedName =13 const suggestedName =
16 auth.status === "authenticated" &&14 auth.status === "authenticated" &&
@@ -21,13 +19,10 @@ export const OnboardingPage = () => { · 4 unmodified lines
21 19
22 const form = useCreateOrganizationForm({20 const form = useCreateOrganizationForm({
23 defaultName: suggestedName,21 defaultName: suggestedName,
24 // On success: the server set a new cookie with the new org; refetch /me22 // The server sets a cookie for the new (or cleared) org on both success and
25 // so AuthGate routes into Shell.23 // failure. The authWriteKeys passed at the mutation site auto-invalidate
26 // On failure: the server may have cleared the cookie because the current24 // authAtom so AuthGate re-routes into Shell or LoginPage as appropriate.
27 // session was too stale to attach the new org. Refetch /me regardless so25 onSuccess: () => {},
28 // AuthGate can route to LoginPage if that's the case.
29 onSuccess: () => refreshAuth(),
30 onFailure: () => refreshAuth(),
31 });26 });
32 27
33 return (28 return (
apps/cloud/src/web/shell.tsx+2−2
@@ -1,7 +1,7 @@
1import { Link, Outlet, useLocation } from "@tanstack/react-router";1import { Link, Outlet, useLocation } from "@tanstack/react-router";
2import { useEffect, useRef, useState } from "react";2import { useEffect, useRef, useState } from "react";
3import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";3import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
4import { sourcesAtom } from "@executor/react/api/atoms";4import { useSourcesWithPending } from "@executor/react/api/optimistic";
5import { useScope } from "@executor/react/api/scope-context";5import { useScope } from "@executor/react/api/scope-context";
6import { Button } from "@executor/react/components/button";6import { Button } from "@executor/react/components/button";
7import { Skeleton } from "@executor/react/components/skeleton";7import { 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
67function SourceList(props: { pathname: string; onNavigate?: () => void }) {67function SourceList(props: { pathname: string; onNavigate?: () => void }) {
68 const scopeId = useScope();68 const scopeId = useScope();
69 const sources = useAtomValue(sourcesAtom(scopeId));69 const sources = useSourcesWithPending(scopeId);
70 70
71 return Result.match(sources, {71 return Result.match(sources, {
72 onInitial: () => (72 onInitial: () => (
apps/local/src/web/shell.tsx+3−2
@@ -1,7 +1,8 @@
1import { Link, Outlet, useLocation } from "@tanstack/react-router";1import { Link, Outlet, useLocation } from "@tanstack/react-router";
2import { useCallback, useEffect, useRef, useState } from "react";2import { useCallback, useEffect, useRef, useState } from "react";
3import { useAtomRefresh, useAtomValue, Result } from "@effect-atom/atom-react";3import { useAtomRefresh, Result } from "@effect-atom/atom-react";
4import { sourcesAtom, toolsAtom } from "@executor/react/api/atoms";4import { sourcesAtom, toolsAtom } from "@executor/react/api/atoms";
5import { useSourcesWithPending } from "@executor/react/api/optimistic";
5import { useScope, useScopeInfo } from "@executor/react/api/scope-context";6import { useScope, useScopeInfo } from "@executor/react/api/scope-context";
6import { Button } from "@executor/react/components/button";7import { Button } from "@executor/react/components/button";
7import { SourceFavicon } from "@executor/react/components/source-favicon";8import { 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
225function SourceList(props: { pathname: string; onNavigate?: () => void }) {226function SourceList(props: { pathname: string; onNavigate?: () => void }) {
226 const scopeId = useScope();227 const scopeId = useScope();
227 const sources = useAtomValue(sourcesAtom(scopeId));228 const sources = useSourcesWithPending(scopeId);
228 229
229 return Result.match(sources, {230 return Result.match(sources, {
230 onInitial: () => (231 onInitial: () => (
package.json+2−1
@@ -37,7 +37,8 @@ · 36 unmodified lines
37 "typecheck": "turbo run typecheck",37 "typecheck": "turbo run typecheck",
38 "typecheck:slow": "turbo run typecheck:slow",38 "typecheck:slow": "turbo run typecheck:slow",
39 "ci": "bun run lint && bun run typecheck && bun run test",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 "lint:fix": "oxlint -c .oxlintrc.jsonc --fix .",42 "lint:fix": "oxlint -c .oxlintrc.jsonc --fix .",
42 "format": "oxfmt .",43 "format": "oxfmt .",
43 "format:check": "oxfmt --check .",44 "format:check": "oxfmt --check .",
packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx+17−5
@@ -1,5 +1,5 @@
1import { useCallback, useEffect, useMemo, useRef, useState } from "react";1import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";2import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
3 3
4import {4import {
5 openOAuthPopup,5 openOAuthPopup,
@@ -7,6 +7,8 @@ import { · 1 unmodified line
7} from "@executor/plugin-oauth2/react";7} from "@executor/plugin-oauth2/react";
8 8
9import { secretsAtom, setSecret } from "@executor/react/api/atoms";9import { secretsAtom, setSecret } from "@executor/react/api/atoms";
10import { usePendingSources } from "@executor/react/api/optimistic";
11import { secretWriteKeys, sourceWriteKeys } from "@executor/react/api/reactivity-keys";
10import { useScope } from "@executor/react/api/scope-context";12import { useScope } from "@executor/react/api/scope-context";
11import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker";13import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker";
12import { SecretId } from "@executor/sdk";14import { SecretId } from "@executor/sdk";
@@ -64,7 +66,6 @@ function InlineCreateSecret(props: { · 51 unmodified lines
64 const [error, setError] = useState<string | null>(null);66 const [error, setError] = useState<string | null>(null);
65 const scopeId = useScope();67 const scopeId = useScope();
66 const doSet = useAtomSet(setSecret, { mode: "promise" });68 const doSet = useAtomSet(setSecret, { mode: "promise" });
67 const refreshSecrets = useAtomRefresh(secretsAtom(scopeId));
68 69
69 const handleSave = async () => {70 const handleSave = async () => {
70 if (!secretId.trim() || !secretValue.trim()) return;71 if (!secretId.trim() || !secretValue.trim()) return;
@@ -78,8 +79,8 @@ function InlineCreateSecret(props: { · 7 unmodified lines
78 name: secretName.trim() || secretId.trim(),79 name: secretName.trim() || secretId.trim(),
79 value: secretValue.trim(),80 value: secretValue.trim(),
80 },81 },
82 reactivityKeys: [...secretWriteKeys],
81 });83 });
82 refreshSecrets();
83 props.onCreated(secretId.trim());84 props.onCreated(secretId.trim());
84 } catch (e) {85 } catch (e) {
85 setError(e instanceof Error ? e.message : "Failed to save secret");86 setError(e instanceof Error ? e.message : "Failed to save secret");
@@ -417,6 +418,7 @@ export default function AddGoogleDiscoverySource(props: { · 331 unmodified lines
417 const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promise" });418 const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promise" });
418 const doStartOAuth = useAtomSet(startGoogleDiscoveryOAuth, { mode: "promise" });419 const doStartOAuth = useAtomSet(startGoogleDiscoveryOAuth, { mode: "promise" });
419 const secrets = useAtomValue(secretsAtom(scopeId));420 const secrets = useAtomValue(secretsAtom(scopeId));
421 const { beginAdd } = usePendingSources();
420 422
421 const canUseOAuth = useMemo(() => (probe?.scopes.length ?? 0) > 0, [probe]);423 const canUseOAuth = useMemo(() => (probe?.scopes.length ?? 0) > 0, [probe]);
422 const secretList: readonly SecretPickerSecret[] = Result.match(secrets, {424 const secretList: readonly SecretPickerSecret[] = Result.match(secrets, {
@@ -556,11 +558,18 @@ export default function AddGoogleDiscoverySource(props: { · 133 unmodified lines
556 if (!probe) return;558 if (!probe) return;
557 setAdding(true);559 setAdding(true);
558 setError(null);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 try {568 try {
560 await doAdd({569 await doAdd({
561 path: { scopeId },570 path: { scopeId },
562 payload: {571 payload: {
563 name: identity.name.trim() || probe.name,572 name: displayName,
564 discoveryUrl: discoveryUrl.trim(),573 discoveryUrl: discoveryUrl.trim(),
565 namespace: slugifyNamespace(identity.namespace) || undefined,574 namespace: slugifyNamespace(identity.namespace) || undefined,
566 auth:575 auth:
@@ -568,13 +577,16 @@ export default function AddGoogleDiscoverySource(props: { · 1 unmodified line
568 ? (oauthAuth ?? { kind: "none" as const })577 ? (oauthAuth ?? { kind: "none" as const })
569 : { kind: "none" as const },578 : { kind: "none" as const },
570 },579 },
580 reactivityKeys: [...sourceWriteKeys],
571 });581 });
572 props.onComplete();582 props.onComplete();
573 } catch (e) {583 } catch (e) {
574 setError(e instanceof Error ? e.message : "Failed to add source");584 setError(e instanceof Error ? e.message : "Failed to add source");
575 setAdding(false);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 const addDisabled =591 const addDisabled =
580 !probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null));592 !probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null));
packages/plugins/google-discovery/src/react/atoms.ts+2−0
@@ -1,10 +1,12 @@
1import type { ScopeId } from "@executor/sdk";1import type { ScopeId } from "@executor/sdk";
2import { ReactivityKey } from "@executor/react/api/reactivity-keys";
2import { GoogleDiscoveryClient } from "./client";3import { GoogleDiscoveryClient } from "./client";
3 4
4export const googleDiscoverySourceAtom = (scopeId: ScopeId, namespace: string) =>5export const googleDiscoverySourceAtom = (scopeId: ScopeId, namespace: string) =>
5 GoogleDiscoveryClient.query("googleDiscovery", "getSource", {6 GoogleDiscoveryClient.query("googleDiscovery", "getSource", {
6 path: { scopeId, namespace },7 path: { scopeId, namespace },
7 timeToLive: "15 seconds",8 timeToLive: "15 seconds",
9 reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools],
8 });10 });
9 11
10export const probeGoogleDiscovery = GoogleDiscoveryClient.mutation(12export const probeGoogleDiscovery = GoogleDiscoveryClient.mutation(
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+30−11
@@ -2,6 +2,8 @@ import { useState } from "react"; · 1 unmodified line
2import { useAtomSet } from "@effect-atom/atom-react";2import { useAtomSet } from "@effect-atom/atom-react";
3 3
4import { useScope } from "@executor/react/api/scope-context";4import { useScope } from "@executor/react/api/scope-context";
5import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
6import { usePendingSources } from "@executor/react/api/optimistic";
5import { HeadersList } from "@executor/react/plugins/headers-list";7import { HeadersList } from "@executor/react/plugins/headers-list";
6import { type HeaderState } from "@executor/react/plugins/secret-header-auth";8import { type HeaderState } from "@executor/react/plugins/secret-header-auth";
7import {9import {
@@ -46,6 +48,7 @@ export default function AddGraphqlSource(props: { · 38 unmodified lines
46 48
47 const scopeId = useScope();49 const scopeId = useScope();
48 const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" });50 const doAdd = useAtomSet(addGraphqlSource, { mode: "promise" });
51 const { beginAdd } = usePendingSources();
49 const secretList = useSecretPickerSecrets();52 const secretList = useSecretPickerSecrets();
50 53
51 const headersValid = headers.every((header) => header.name.trim() && header.secretId);54 const headersValid = headers.every((header) => header.name.trim() && header.secretId);
@@ -54,31 +57,47 @@ export default function AddGraphqlSource(props: { · 2 unmodified lines
54 const handleAdd = async () => {57 const handleAdd = async () => {
55 setAdding(true);58 setAdding(true);
56 setAddError(null);59 setAddError(null);
57 try {60 const headerMap: Record<string, HeaderValue> = {};
58 const headerMap: Record<string, HeaderValue> = {};61 for (const header of headers) {
59 for (const header of headers) {62 const name = header.name.trim();
60 const name = header.name.trim();63 if (name && header.secretId) {
61 if (name && header.secretId) {64 headerMap[name] = {
62 headerMap[name] = {65 secretId: header.secretId,
63 secretId: header.secretId,66 ...(header.prefix ? { prefix: header.prefix } : {}),
64 ...(header.prefix ? { prefix: header.prefix } : {}),67 };
65 };
66 }
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 await doAdd({85 await doAdd({
70 path: { scopeId },86 path: { scopeId },
71 payload: {87 payload: {
72 endpoint: endpoint.trim(),88 endpoint: trimmedEndpoint,
73 name: identity.name.trim() || undefined,89 name: identity.name.trim() || undefined,
74 namespace: slugifyNamespace(identity.namespace) || undefined,90 namespace: slugifyNamespace(identity.namespace) || undefined,
75 ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}),91 ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}),
76 },92 },
93 reactivityKeys: sourceWriteKeys,
77 });94 });
78 props.onComplete();95 props.onComplete();
79 } catch (e) {96 } catch (e) {
80 setAddError(e instanceof Error ? e.message : "Failed to add source");97 setAddError(e instanceof Error ? e.message : "Failed to add source");
81 setAdding(false);98 setAdding(false);
99 } finally {
100 placeholder.done();
82 }101 }
83 };102 };
84 103
packages/plugins/graphql/src/react/EditGraphqlSource.tsx+3−3
@@ -1,7 +1,8 @@
1import { useState } from "react";1import { useState } from "react";
2import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";2import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
3import { graphqlSourceAtom, updateGraphqlSource } from "./atoms";3import { graphqlSourceAtom, updateGraphqlSource } from "./atoms";
4import { useScope } from "@executor/react/api/scope-context";4import { useScope } from "@executor/react/api/scope-context";
5import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
5import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";6import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";
6import {7import {
7 headerValueToState,8 headerValueToState,
@@ -35,7 +36,6 @@ function EditForm(props: { · 27 unmodified lines
35}) {36}) {
36 const scopeId = useScope();37 const scopeId = useScope();
37 const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promise" });38 const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promise" });
38 const refreshSource = useAtomRefresh(graphqlSourceAtom(scopeId, props.sourceId));
39 const secretList = useSecretPickerSecrets();39 const secretList = useSecretPickerSecrets();
40 40
41 const identity = useSourceIdentity({41 const identity = useSourceIdentity({
@@ -70,8 +70,8 @@ function EditForm(props: { · 28 unmodified lines
70 endpoint: endpoint.trim() || undefined,70 endpoint: endpoint.trim() || undefined,
71 headers: headersFromState(headers),71 headers: headersFromState(headers),
72 },72 },
73 reactivityKeys: sourceWriteKeys,
73 });74 });
74 refreshSource();
75 setDirty(false);75 setDirty(false);
76 props.onSave();76 props.onSave();
77 } catch (e) {77 } catch (e) {
packages/plugins/graphql/src/react/atoms.ts+2−0
@@ -1,4 +1,5 @@
1import type { ScopeId } from "@executor/sdk";1import type { ScopeId } from "@executor/sdk";
2import { ReactivityKey } from "@executor/react/api/reactivity-keys";
2import { GraphqlClient } from "./client";3import { GraphqlClient } from "./client";
3 4
4// ---------------------------------------------------------------------------5// ---------------------------------------------------------------------------
@@ -9,6 +10,7 @@ export const graphqlSourceAtom = (scopeId: ScopeId, namespace: string) => · 4 unmodified lines
9 GraphqlClient.query("graphql", "getSource", {10 GraphqlClient.query("graphql", "getSource", {
10 path: { scopeId, namespace },11 path: { scopeId, namespace },
11 timeToLive: "15 seconds",12 timeToLive: "15 seconds",
13 reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools],
12 });14 });
13 15
14// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
packages/plugins/mcp/src/react/AddMcpSource.tsx+55−29
@@ -35,6 +35,8 @@ import { · 34 unmodified lines
35import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";35import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";
36 36
37type RemoteAuthMode = "none" | "header" | "oauth2";37type RemoteAuthMode = "none" | "header" | "oauth2";
38import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
39import { usePendingSources } from "@executor/react/api/optimistic";
38import { probeMcpEndpoint, addMcpSource, startMcpOAuth } from "./atoms";40import { probeMcpEndpoint, addMcpSource, startMcpOAuth } from "./atoms";
39import { mcpPresets, type McpPreset } from "../sdk/presets";41import { mcpPresets, type McpPreset } from "../sdk/presets";
40 42
@@ -330,6 +332,7 @@ export default function AddMcpSource(props: { · 289 unmodified lines
330 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" });332 const doProbe = useAtomSet(probeMcpEndpoint, { mode: "promise" });
331 const doAdd = useAtomSet(addMcpSource, { mode: "promise" });333 const doAdd = useAtomSet(addMcpSource, { mode: "promise" });
332 const doStartOAuth = useAtomSet(startMcpOAuth, { mode: "promise" });334 const doStartOAuth = useAtomSet(startMcpOAuth, { mode: "promise" });
335 const { beginAdd } = usePendingSources();
333 const secretList = useSecretPickerSecrets();336 const secretList = useSecretPickerSecrets();
334 337
335 const [remoteAuthMode, setRemoteAuthMode] = useState<RemoteAuthMode>("none");338 const [remoteAuthMode, setRemoteAuthMode] = useState<RemoteAuthMode>("none");
@@ -462,42 +465,51 @@ export default function AddMcpSource(props: { · 126 unmodified lines
462 const handleAddRemote = useCallback(async () => {465 const handleAddRemote = useCallback(async () => {
463 if (!probe) return;466 if (!probe) return;
464 dispatch({ type: "add-start" });467 dispatch({ type: "add-start" });
465 try {468 const headerAuth = remoteAuthHeaders[0];
466 const headerAuth = remoteAuthHeaders[0];469 const auth =
467 const auth =470 remoteAuthMode === "header" && headerAuth?.secretId
468 remoteAuthMode === "header" && headerAuth?.secretId471 ? {
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,479 kind: "oauth2" as const,
471 headerName: headerAuth.name.trim(),480 accessTokenSecretId: tokens.accessTokenSecretId,
472 secretId: headerAuth.secretId,481 refreshTokenSecretId: tokens.refreshTokenSecretId,
473 ...(headerAuth.prefix ? { prefix: headerAuth.prefix } : {}),482 tokenType: tokens.tokenType,
483 expiresAt: tokens.expiresAt,
484 scope: tokens.scope,
474 }485 }
475 : remoteAuthMode === "oauth2" && tokens486 : { kind: "none" as const };
476 ? {487 const headers = Object.fromEntries(
477 kind: "oauth2" as const,488 remoteHeaders
478 accessTokenSecretId: tokens.accessTokenSecretId,489 .map((header) => [header.name.trim(), header.value.trim()] as const)
479 refreshTokenSecretId: tokens.refreshTokenSecretId,490 .filter(([name, value]) => name && value),
480 tokenType: tokens.tokenType,491 );
481 expiresAt: tokens.expiresAt,492 const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name;
482 scope: tokens.scope,493 const slugNamespace = slugifyNamespace(remoteIdentity.namespace);
483 }494 const placeholderId = slugNamespace || `pending:${crypto.randomUUID()}`;
484 : { kind: "none" as const };495 const placeholder = beginAdd({
485 const headers = Object.fromEntries(496 id: placeholderId,
486 remoteHeaders497 name: displayName,
487 .map((header) => [header.name.trim(), header.value.trim()] as const)498 kind: "mcp",
488 .filter(([name, value]) => name && value),499 url: state.url.trim(),
489 );500 });
490 501 try {
491 await doAdd({502 await doAdd({
492 path: { scopeId },503 path: { scopeId },
493 payload: {504 payload: {
494 transport: "remote" as const,505 transport: "remote" as const,
495 name: remoteIdentity.name.trim() || probe.serverName || probe.name,506 name: displayName,
496 namespace: slugifyNamespace(remoteIdentity.namespace) || undefined,507 namespace: slugNamespace || undefined,
497 endpoint: state.url.trim(),508 endpoint: state.url.trim(),
498 auth,509 auth,
499 ...(Object.keys(headers).length > 0 ? { headers } : {}),510 ...(Object.keys(headers).length > 0 ? { headers } : {}),
500 },511 },
512 reactivityKeys: sourceWriteKeys,
501 });513 });
502 props.onComplete();514 props.onComplete();
503 } catch (e) {515 } catch (e) {
@@ -505,6 +517,8 @@ export default function AddMcpSource(props: { · 1 unmodified line
505 type: "add-fail",517 type: "add-fail",
506 error: e instanceof Error ? e.message : "Failed to add source",518 error: e instanceof Error ? e.message : "Failed to add source",
507 });519 });
520 } finally {
521 placeholder.done();
508 }522 }
509 }, [523 }, [
510 probe,524 probe,
@@ -517,6 +531,7 @@ export default function AddMcpSource(props: { · 6 unmodified lines
517 doAdd,531 doAdd,
518 props,532 props,
519 scopeId,533 scopeId,
534 beginAdd,
520 ]);535 ]);
521 536
522 // ---- Stdio actions ----537 // ---- Stdio actions ----
@@ -549,24 +564,35 @@ export default function AddMcpSource(props: { · 26 unmodified lines
549 if (!cmd) return;564 if (!cmd) return;
550 setStdioAdding(true);565 setStdioAdding(true);
551 setStdioError(null);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 try {575 try {
553 await doAdd({576 await doAdd({
554 path: { scopeId },577 path: { scopeId },
555 payload: {578 payload: {
556 transport: "stdio" as const,579 transport: "stdio" as const,
557 name: stdioIdentity.name.trim() || cmd,580 name: displayName,
558 namespace: slugifyNamespace(stdioIdentity.namespace) || undefined,581 namespace: slugNamespace || undefined,
559 command: cmd,582 command: cmd,
560 args: parseStdioArgs(stdioArgs),583 args: parseStdioArgs(stdioArgs),
561 env: parseStdioEnv(stdioEnv),584 env: parseStdioEnv(stdioEnv),
562 },585 },
586 reactivityKeys: sourceWriteKeys,
563 });587 });
564 props.onComplete();588 props.onComplete();
565 } catch (e) {589 } catch (e) {
566 setStdioError(e instanceof Error ? e.message : "Failed to add source");590 setStdioError(e instanceof Error ? e.message : "Failed to add source");
567 setStdioAdding(false);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 // ---- Render ----597 // ---- Render ----
572 598
packages/plugins/mcp/src/react/EditMcpSource.tsx+3−3
@@ -1,7 +1,8 @@
1import { useState } from "react";1import { useState } from "react";
2import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";2import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
3import { mcpSourceAtom, updateMcpSource } from "./atoms";3import { mcpSourceAtom, updateMcpSource } from "./atoms";
4import { useScope } from "@executor/react/api/scope-context";4import { useScope } from "@executor/react/api/scope-context";
5import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
5import {6import {
6 SourceIdentityFields,7 SourceIdentityFields,
7 useSourceIdentity,8 useSourceIdentity,
@@ -37,7 +38,6 @@ function RemoteEditForm(props: { · 29 unmodified lines
37}) {38}) {
38 const scopeId = useScope();39 const scopeId = useScope();
39 const doUpdate = useAtomSet(updateMcpSource, { mode: "promise" });40 const doUpdate = useAtomSet(updateMcpSource, { mode: "promise" });
40 const refreshSource = useAtomRefresh(mcpSourceAtom(scopeId, props.sourceId));
41 41
42 const identity = useSourceIdentity({42 const identity = useSourceIdentity({
43 fallbackName: props.initial.name,43 fallbackName: props.initial.name,
@@ -90,8 +90,8 @@ function RemoteEditForm(props: { · 46 unmodified lines
90 endpoint: endpoint.trim() || undefined,90 endpoint: endpoint.trim() || undefined,
91 headers: headersObj,91 headers: headersObj,
92 },92 },
93 reactivityKeys: sourceWriteKeys,
93 });94 });
94 refreshSource();
95 setDirty(false);95 setDirty(false);
96 props.onSave();96 props.onSave();
97 } catch (e) {97 } catch (e) {
packages/plugins/mcp/src/react/atoms.ts+2−0
@@ -1,4 +1,5 @@
1import type { ScopeId } from "@executor/sdk";1import type { ScopeId } from "@executor/sdk";
2import { ReactivityKey } from "@executor/react/api/reactivity-keys";
2import { McpClient } from "./client";3import { McpClient } from "./client";
3 4
4// ---------------------------------------------------------------------------5// ---------------------------------------------------------------------------
@@ -9,6 +10,7 @@ export const mcpSourceAtom = (scopeId: ScopeId, namespace: string) => · 4 unmodified lines
9 McpClient.query("mcp", "getSource", {10 McpClient.query("mcp", "getSource", {
10 path: { scopeId, namespace },11 path: { scopeId, namespace },
11 timeToLive: "15 seconds",12 timeToLive: "15 seconds",
13 reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools],
12 });14 });
13 15
14// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
packages/plugins/onepassword/src/react/OnePasswordSettings.tsx+4−6
@@ -1,6 +1,7 @@
1import { useState } from "react";1import { useState } from "react";
2import { useAtomSet, useAtomValue, useAtomRefresh } from "@effect-atom/atom-react";2import { useAtomSet, useAtomValue } from "@effect-atom/atom-react";
3import { Result } from "@effect-atom/atom-react";3import { Result } from "@effect-atom/atom-react";
4import { ReactivityKey } from "@executor/react/api/reactivity-keys";
4import { useScope } from "@executor/react/api/scope-context";5import { useScope } from "@executor/react/api/scope-context";
5import { Button } from "@executor/react/components/button";6import { Button } from "@executor/react/components/button";
6import { Input } from "@executor/react/components/input";7import { Input } from "@executor/react/components/input";
@@ -139,7 +140,6 @@ function ConfigDialog(props: { · 132 unmodified lines
139 140
140 const scopeId = useScope();141 const scopeId = useScope();
141 const doConfigure = useAtomSet(configureOnePassword, { mode: "promise" });142 const doConfigure = useAtomSet(configureOnePassword, { mode: "promise" });
142 const refreshConfig = useAtomRefresh(onepasswordConfigAtom(scopeId));
143 143
144 const reset = () => {144 const reset = () => {
145 if (!isEdit) {145 if (!isEdit) {
@@ -165,9 +165,9 @@ function ConfigDialog(props: { · 19 unmodified lines
165 await doConfigure({165 await doConfigure({
166 path: { scopeId },166 path: { scopeId },
167 payload: { auth, vaultId: vaultId.trim(), name: vaultName.trim() || "1Password" },167 payload: { auth, vaultId: vaultId.trim(), name: vaultName.trim() || "1Password" },
168 reactivityKeys: [ReactivityKey.secrets],
168 });169 });
169 props.onOpenChange(false);170 props.onOpenChange(false);
170 refreshConfig();
171 reset();171 reset();
172 } catch (e) {172 } catch (e) {
173 setError(e instanceof Error ? e.message : "Failed to save configuration");173 setError(e instanceof Error ? e.message : "Failed to save configuration");
@@ -296,12 +296,10 @@ export default function OnePasswordSettings() { · 122 unmodified lines
296 const scopeId = useScope();296 const scopeId = useScope();
297 const configResult = useAtomValue(onepasswordConfigAtom(scopeId));297 const configResult = useAtomValue(onepasswordConfigAtom(scopeId));
298 const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promise" });298 const doRemove = useAtomSet(removeOnePasswordConfig, { mode: "promise" });
299 const refreshConfig = useAtomRefresh(onepasswordConfigAtom(scopeId));
300 299
301 const handleRemove = async () => {300 const handleRemove = async () => {
302 try {301 try {
303 await doRemove({ path: { scopeId } });302 await doRemove({ path: { scopeId }, reactivityKeys: [ReactivityKey.secrets] });
304 refreshConfig();
305 } catch {303 } catch {
306 /* TODO: toast */304 /* TODO: toast */
307 }305 }
packages/plugins/onepassword/src/react/atoms.ts+6−0
@@ -1,6 +1,9 @@
1import type { ScopeId } from "@executor/sdk";1import type { ScopeId } from "@executor/sdk";
2import { ReactivityKey } from "@executor/react/api/reactivity-keys";
2import { OnePasswordClient } from "./client";3import { OnePasswordClient } from "./client";
3 4
5export const onepasswordWriteKeys = [ReactivityKey.secrets] as const;
6
4// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
5// Query atoms8// Query atoms
6// ---------------------------------------------------------------------------9// ---------------------------------------------------------------------------
@@ -9,12 +12,14 @@ export const onepasswordConfigAtom = (scopeId: ScopeId) => · 2 unmodified lines
9 OnePasswordClient.query("onepassword", "getConfig", {12 OnePasswordClient.query("onepassword", "getConfig", {
10 path: { scopeId },13 path: { scopeId },
11 timeToLive: "30 seconds",14 timeToLive: "30 seconds",
15 reactivityKeys: [ReactivityKey.secrets],
12 });16 });
13 17
14export const onepasswordStatusAtom = (scopeId: ScopeId) =>18export const onepasswordStatusAtom = (scopeId: ScopeId) =>
15 OnePasswordClient.query("onepassword", "status", {19 OnePasswordClient.query("onepassword", "status", {
16 path: { scopeId },20 path: { scopeId },
17 timeToLive: "15 seconds",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 path: { scopeId },35 path: { scopeId },
31 urlParams: { authKind, account },36 urlParams: { authKind, account },
32 timeToLive: "30 seconds",37 timeToLive: "30 seconds",
38 reactivityKeys: [ReactivityKey.secrets],
33 });39 });
34 40
35// ---------------------------------------------------------------------------41// ---------------------------------------------------------------------------
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+18−0
@@ -6,6 +6,8 @@ import { openOAuthPopup, type OAuthPopupResult } from "@executor/plugin-oauth2/r · 5 unmodified lines
6 6
7import { SecretPicker } from "@executor/react/plugins/secret-picker";7import { SecretPicker } from "@executor/react/plugins/secret-picker";
8import { useScope } from "@executor/react/api/scope-context";8import { useScope } from "@executor/react/api/scope-context";
9import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
10import { usePendingSources } from "@executor/react/api/optimistic";
9import { HeadersList } from "@executor/react/plugins/headers-list";11import { HeadersList } from "@executor/react/plugins/headers-list";
10import {12import {
11 matchPresetKey,13 matchPresetKey,
@@ -171,6 +173,7 @@ export default function AddOpenApiSource(props: { · 159 unmodified lines
171 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });173 const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });
172 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });174 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });
173 const doStartOAuth = useAtomSet(startOpenApiOAuth, { mode: "promise" });175 const doStartOAuth = useAtomSet(startOpenApiOAuth, { mode: "promise" });
176 const { beginAdd } = usePendingSources();
174 const secretList = useSecretPickerSecrets();177 const secretList = useSecretPickerSecrets();
175 178
176 // Keep the latest handleAnalyze in a ref so the debounced effect doesn't179 // 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 const handleAdd = async () => {459 const handleAdd = async () => {
457 setAdding(true);460 setAdding(true);
458 setAddError(null);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 try {474 try {
460 await doAdd({475 await doAdd({
461 path: { scopeId },476 path: { scopeId },
@@ -467,11 +482,14 @@ export default function AddOpenApiSource(props: { · 5 unmodified lines
467 ...(hasHeaders ? { headers: allHeaders } : {}),482 ...(hasHeaders ? { headers: allHeaders } : {}),
468 ...(oauth2Auth ? { oauth2: oauth2Auth } : {}),483 ...(oauth2Auth ? { oauth2: oauth2Auth } : {}),
469 },484 },
485 reactivityKeys: sourceWriteKeys,
470 });486 });
471 props.onComplete();487 props.onComplete();
472 } catch (e) {488 } catch (e) {
473 setAddError(e instanceof Error ? e.message : "Failed to add source");489 setAddError(e instanceof Error ? e.message : "Failed to add source");
474 setAdding(false);490 setAdding(false);
491 } finally {
492 placeholder.done();
475 }493 }
476 };494 };
477 495
packages/plugins/openapi/src/react/EditOpenApiSource.tsx+3−3
@@ -1,7 +1,8 @@
1import { useState } from "react";1import { useState } from "react";
2import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";2import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
3import { openApiSourceAtom, updateOpenApiSource } from "./atoms";3import { openApiSourceAtom, updateOpenApiSource } from "./atoms";
4import { useScope } from "@executor/react/api/scope-context";4import { useScope } from "@executor/react/api/scope-context";
5import { sourceWriteKeys } from "@executor/react/api/reactivity-keys";
5import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";6import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets";
6import {7import {
7 headerValueToState,8 headerValueToState,
@@ -35,7 +36,6 @@ function EditForm(props: { · 27 unmodified lines
35}) {36}) {
36 const scopeId = useScope();37 const scopeId = useScope();
37 const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promise" });38 const doUpdate = useAtomSet(updateOpenApiSource, { mode: "promise" });
38 const refreshSource = useAtomRefresh(openApiSourceAtom(scopeId, props.sourceId));
39 const secretList = useSecretPickerSecrets();39 const secretList = useSecretPickerSecrets();
40 40
41 const identity = useSourceIdentity({41 const identity = useSourceIdentity({
@@ -70,8 +70,8 @@ function EditForm(props: { · 28 unmodified lines
70 baseUrl: baseUrl.trim() || undefined,70 baseUrl: baseUrl.trim() || undefined,
71 headers: headersFromState(headers),71 headers: headersFromState(headers),
72 },72 },
73 reactivityKeys: sourceWriteKeys,
73 });74 });
74 refreshSource();
75 setDirty(false);75 setDirty(false);
76 props.onSave();76 props.onSave();
77 } catch (e) {77 } catch (e) {
packages/plugins/openapi/src/react/atoms.ts+2−0
@@ -1,4 +1,5 @@
1import type { ScopeId } from "@executor/sdk";1import type { ScopeId } from "@executor/sdk";
2import { ReactivityKey } from "@executor/react/api/reactivity-keys";
2import { OpenApiClient } from "./client";3import { OpenApiClient } from "./client";
3 4
4// ---------------------------------------------------------------------------5// ---------------------------------------------------------------------------
@@ -9,6 +10,7 @@ export const openApiSourceAtom = (scopeId: ScopeId, namespace: string) => · 4 unmodified lines
9 OpenApiClient.query("openapi", "getSource", {10 OpenApiClient.query("openapi", "getSource", {
10 path: { scopeId, namespace },11 path: { scopeId, namespace },
11 timeToLive: "15 seconds",12 timeToLive: "15 seconds",
13 reactivityKeys: [ReactivityKey.sources, ReactivityKey.tools],
12 });14 });
13 15
14// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
packages/react/src/api/atoms.tsx+11−1
@@ -2,6 +2,7 @@ import type { ScopeId, ToolId, SecretId } from "@executor/sdk"; · 1 unmodified line
2import { Atom } from "@effect-atom/atom-react";2import { Atom } from "@effect-atom/atom-react";
3 3
4import { ExecutorApiClient } from "./client";4import { ExecutorApiClient } from "./client";
5import { ReactivityKey } from "./reactivity-keys";
5 6
6// ---------------------------------------------------------------------------7// ---------------------------------------------------------------------------
7// Scope — fetched from the server8// Scope — fetched from the server
@@ -9,6 +10,7 @@ import { ExecutorApiClient } from "./client"; · 1 unmodified line
9 10
10export const scopeAtom = ExecutorApiClient.query("scope", "info", {11export const scopeAtom = ExecutorApiClient.query("scope", "info", {
11 timeToLive: "5 minutes",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 ExecutorApiClient.query("tools", "list", {21 ExecutorApiClient.query("tools", "list", {
20 path: { scopeId },22 path: { scopeId },
21 timeToLive: "30 seconds",23 timeToLive: "30 seconds",
24 reactivityKeys: [ReactivityKey.tools],
22 });25 });
23 26
24/** Tools for a specific source */27/** Tools for a specific source */
@@ -26,18 +29,21 @@ export const sourceToolsAtom = (sourceId: string, scopeId: ScopeId) => · 1 unmodified line
26 ExecutorApiClient.query("sources", "tools", {29 ExecutorApiClient.query("sources", "tools", {
27 path: { scopeId, sourceId },30 path: { scopeId, sourceId },
28 timeToLive: "30 seconds",31 timeToLive: "30 seconds",
32 reactivityKeys: [ReactivityKey.tools],
29 });33 });
30 34
31export const toolSchemaAtom = (scopeId: ScopeId, toolId: ToolId) =>35export const toolSchemaAtom = (scopeId: ScopeId, toolId: ToolId) =>
32 ExecutorApiClient.query("tools", "schema", {36 ExecutorApiClient.query("tools", "schema", {
33 path: { scopeId, toolId },37 path: { scopeId, toolId },
34 timeToLive: "1 minute",38 timeToLive: "1 minute",
39 reactivityKeys: [ReactivityKey.tools],
35 });40 });
36 41
37export const sourcesAtom = (scopeId: ScopeId) =>42export const sourcesAtom = (scopeId: ScopeId) =>
38 ExecutorApiClient.query("sources", "list", {43 ExecutorApiClient.query("sources", "list", {
39 path: { scopeId },44 path: { scopeId },
40 timeToLive: "30 seconds",45 timeToLive: "30 seconds",
46 reactivityKeys: [ReactivityKey.sources],
41 });47 });
42 48
43/** Single source by id — derived from the sources list */49/** Single source by id — derived from the sources list */
@@ -48,16 +54,20 @@ export const secretsAtom = (scopeId: ScopeId) => · 4 unmodified lines
48 ExecutorApiClient.query("secrets", "list", {54 ExecutorApiClient.query("secrets", "list", {
49 path: { scopeId },55 path: { scopeId },
50 timeToLive: "30 seconds",56 timeToLive: "30 seconds",
57 reactivityKeys: [ReactivityKey.secrets],
51 });58 });
52 59
53export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) =>60export const secretStatusAtom = (scopeId: ScopeId, secretId: SecretId) =>
54 ExecutorApiClient.query("secrets", "status", {61 ExecutorApiClient.query("secrets", "status", {
55 path: { scopeId, secretId },62 path: { scopeId, secretId },
56 timeToLive: "15 seconds",63 timeToLive: "15 seconds",
64 reactivityKeys: [ReactivityKey.secrets],
57 });65 });
58 66
59// ---------------------------------------------------------------------------67// ---------------------------------------------------------------------------
60// Mutation atoms68// 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
63export const setSecret = ExecutorApiClient.mutation("secrets", "set");73export const setSecret = ExecutorApiClient.mutation("secrets", "set");
packages/react/src/api/optimistic.tsx+143−0
@@ -0,0 +1,143 @@
1import * as React from "react";
2import { Atom, Result, useAtomSet, useAtomValue } from "@effect-atom/atom-react";
3import type { ScopeId } from "@executor/sdk";
4
5import { 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
22export const PendingResource = {
23 sources: "sources",
24} as const;
25
26export interface PendingEntry<T> {
27 readonly id: string;
28 readonly value: T;
29}
30
31const pendingFamily = Atom.family((_resource: string) =>
32 Atom.make<ReadonlyArray<PendingEntry<unknown>>>([]),
33);
34
35const atomFor = <T,>(resource: string) =>
36 pendingFamily(resource) as ReturnType<typeof pendingFamily> & {
37 readonly __t?: T;
38 };
39
40export 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 */
69export 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
88export 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 */
99export 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 */
132export 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};
packages/react/src/api/reactivity-keys.tsx+57−0
@@ -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 */
20export 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. */
33export const sourceWriteKeys = [ReactivityKey.sources, ReactivityKey.tools] as const;
34
35/** Mutations that mint or revoke secrets. */
36export const secretWriteKeys = [ReactivityKey.secrets] as const;
37
38/** Mutations that change scope membership/info. */
39export const scopeWriteKeys = [ReactivityKey.scope] as const;
40
41/** Cloud-only: org membership mutations. */
42export const orgMemberWriteKeys = [ReactivityKey.orgMembers] as const;
43
44/** Cloud-only: org domain mutations. */
45export const orgDomainWriteKeys = [ReactivityKey.orgDomains] as const;
46
47/** Cloud-only: org info mutations (name, etc.) — also touches scope/auth. */
48export const orgInfoWriteKeys = [ReactivityKey.orgInfo, ReactivityKey.auth] as const;
49
50/** Cloud-only: auth mutations (org switch/create) — invalidate everything user-visible. */
51export const authWriteKeys = [
52 ReactivityKey.auth,
53 ReactivityKey.orgInfo,
54 ReactivityKey.orgMembers,
55 ReactivityKey.orgDomains,
56 ReactivityKey.scope,
57] as const;
packages/react/src/pages/secrets.tsx+4−5
@@ -1,6 +1,7 @@
1import { useState, Suspense } from "react";1import { useState, Suspense } from "react";
2import { useAtomValue, useAtomSet, useAtomRefresh, Result } from "@effect-atom/atom-react";2import { useAtomValue, useAtomSet, Result } from "@effect-atom/atom-react";
3import { secretsAtom, setSecret, removeSecret } from "../api/atoms";3import { secretsAtom, setSecret, removeSecret } from "../api/atoms";
4import { secretWriteKeys } from "../api/reactivity-keys";
4import type { SecretProviderPlugin } from "../plugins/secret-provider-plugin";5import type { SecretProviderPlugin } from "../plugins/secret-provider-plugin";
5import { SecretId } from "@executor/sdk";6import { SecretId } from "@executor/sdk";
6import { useScope } from "../hooks/use-scope";7import { useScope } from "../hooks/use-scope";
@@ -72,7 +73,6 @@ function AddSecretDialog(props: { · 65 unmodified lines
72 73
73 const scopeId = useScope();74 const scopeId = useScope();
74 const doSet = useAtomSet(setSecret, { mode: "promise" });75 const doSet = useAtomSet(setSecret, { mode: "promise" });
75 const refresh = useAtomRefresh(secretsAtom(scopeId));
76 76
77 const reset = () => {77 const reset = () => {
78 setId("");78 setId("");
@@ -96,10 +96,10 @@ function AddSecretDialog(props: { · 17 unmodified lines
96 value: value.trim(),96 value: value.trim(),
97 provider: provider === "auto" ? undefined : provider,97 provider: provider === "auto" ? undefined : provider,
98 },98 },
99 reactivityKeys: secretWriteKeys,
99 });100 });
100 reset();101 reset();
101 props.onOpenChange(false);102 props.onOpenChange(false);
102 refresh();
103 } catch (e) {103 } catch (e) {
104 setError(e instanceof Error ? e.message : "Failed to save secret");104 setError(e instanceof Error ? e.message : "Failed to save secret");
105 setSaving(false);105 setSaving(false);
@@ -292,7 +292,6 @@ export function SecretsPage(props: { · 186 unmodified lines
292 const scopeId = useScope();292 const scopeId = useScope();
293 const secrets = useAtomValue(secretsAtom(scopeId));293 const secrets = useAtomValue(secretsAtom(scopeId));
294 const doRemove = useAtomSet(removeSecret, { mode: "promise" });294 const doRemove = useAtomSet(removeSecret, { mode: "promise" });
295 const refresh = useAtomRefresh(secretsAtom(scopeId));
296 295
297 const handleRemove = async (secretId: string) => {296 const handleRemove = async (secretId: string) => {
298 try {297 try {
@@ -301,8 +300,8 @@ export function SecretsPage(props: { · 2 unmodified lines
301 scopeId,300 scopeId,
302 secretId: SecretId.make(secretId),301 secretId: SecretId.make(secretId),
303 },302 },
303 reactivityKeys: secretWriteKeys,
304 });304 });
305 refresh();
306 } catch {305 } catch {
307 // TODO: toast306 // TODO: toast
308 }307 }
packages/react/src/pages/source-detail.tsx+3−5
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
8 removeSource,8 removeSource,
9 refreshSource,9 refreshSource,
10} from "../api/atoms";10} from "../api/atoms";
11import { sourceWriteKeys } from "../api/reactivity-keys";
11import { ToolTree } from "../components/tool-tree";12import { ToolTree } from "../components/tool-tree";
12import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail";13import { ToolDetail, ToolDetailEmpty } from "../components/tool-detail";
13import type { ToolSummary } from "../components/tool-tree";14import type { ToolSummary } from "../components/tool-tree";
@@ -81,8 +82,8 @@ export function SourceDetailPage(props: { · 67 unmodified lines
81 try {82 try {
82 await doRemove({83 await doRemove({
83 path: { scopeId, sourceId: namespace },84 path: { scopeId, sourceId: namespace },
85 reactivityKeys: sourceWriteKeys,
84 });86 });
85 refreshSources();
86 void navigate({ to: "/" });87 void navigate({ to: "/" });
87 } catch {88 } catch {
88 setDeleting(false);89 setDeleting(false);
@@ -95,9 +96,8 @@ export function SourceDetailPage(props: { · 6 unmodified lines
95 try {96 try {
96 await doRefresh({97 await doRefresh({
97 path: { scopeId, sourceId: namespace },98 path: { scopeId, sourceId: namespace },
99 reactivityKeys: sourceWriteKeys,
98 });100 });
99 refreshTools();
100 refreshSources();
101 } finally {101 } finally {
102 setRefreshing(false);102 setRefreshing(false);
103 }103 }
@@ -105,8 +105,6 @@ export function SourceDetailPage(props: { · 1 unmodified line
105 105
106 const handleEditSave = () => {106 const handleEditSave = () => {
107 setEditing(false);107 setEditing(false);
108 refreshSources();
109 refreshTools();
110 };108 };
111 109
112 return (110 return (
packages/react/src/pages/sources-add.tsx+0−6
@@ -1,9 +1,6 @@
1import { Suspense } from "react";1import { Suspense } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";2import { Link, useNavigate } from "@tanstack/react-router";
3import type { SourcePlugin } from "../plugins/source-plugin";3import type { SourcePlugin } from "../plugins/source-plugin";
4import { useAtomRefresh } from "@effect-atom/atom-react";
5import { sourcesAtom } from "../api/atoms";
6import { useScope } from "../hooks/use-scope";
7 4
8// ---------------------------------------------------------------------------5// ---------------------------------------------------------------------------
9// Page6// Page
@@ -17,8 +14,6 @@ export function SourcesAddPage(props: { · 7 unmodified lines
17 sourcePlugins: readonly SourcePlugin[];14 sourcePlugins: readonly SourcePlugin[];
18}) {15}) {
19 const { pluginKey, url, preset, namespace, sourcePlugins } = props;16 const { pluginKey, url, preset, namespace, sourcePlugins } = props;
20 const scopeId = useScope();
21 const refreshSources = useAtomRefresh(sourcesAtom(scopeId));
22 const navigate = useNavigate();17 const navigate = useNavigate();
23 18
24 const plugin = sourcePlugins.find((p) => p.key === pluginKey);19 const plugin = sourcePlugins.find((p) => p.key === pluginKey);
@@ -57,7 +52,6 @@ export function SourcesAddPage(props: { · 32 unmodified lines
57 initialPreset={preset}52 initialPreset={preset}
58 initialNamespace={namespace}53 initialNamespace={namespace}
59 onComplete={() => {54 onComplete={() => {
60 refreshSources();
61 void navigate({ to: "/" });55 void navigate({ to: "/" });
62 }}56 }}
63 onCancel={() => {57 onCancel={() => {
packages/react/src/pages/sources.tsx+4−3
@@ -1,7 +1,8 @@
1import { useState, useCallback, useMemo } from "react";1import { useState, useCallback, useMemo } from "react";
2import { Link, useNavigate } from "@tanstack/react-router";2import { Link, useNavigate } from "@tanstack/react-router";
3import { Result, useAtomValue, useAtomSet } from "@effect-atom/atom-react";3import { Result, useAtomSet } from "@effect-atom/atom-react";
4import { sourcesAtom, detectSource } from "../api/atoms";4import { detectSource } from "../api/atoms";
5import { useSourcesWithPending } from "../api/optimistic";
5import { useScope } from "../hooks/use-scope";6import { useScope } from "../hooks/use-scope";
6import type { SourcePlugin, SourcePreset } from "../plugins/source-plugin";7import type { SourcePlugin, SourcePreset } from "../plugins/source-plugin";
7import { McpInstallCard } from "../components/mcp-install-card";8import { McpInstallCard } from "../components/mcp-install-card";
@@ -41,7 +42,7 @@ export function SourcesPage(props: { sourcePlugins: readonly SourcePlugin[] }) { · 33 unmodified lines
41 const [error, setError] = useState<string | null>(null);42 const [error, setError] = useState<string | null>(null);
42 43
43 const scopeId = useScope();44 const scopeId = useScope();
44 const sources = useAtomValue(sourcesAtom(scopeId));45 const sources = useSourcesWithPending(scopeId);
45 const doDetect = useAtomSet(detectSource, { mode: "promise" });46 const doDetect = useAtomSet(detectSource, { mode: "promise" });
46 const navigate = useNavigate();47 const navigate = useNavigate();
47 48
packages/react/src/plugins/secret-header-auth.tsx+4−4
@@ -1,7 +1,8 @@
1import { useId, useState } from "react";1import { useId, useState } from "react";
2import { useAtomRefresh, useAtomSet } from "@effect-atom/atom-react";2import { useAtomSet } from "@effect-atom/atom-react";
3 3
4import { secretsAtom, setSecret, resolveSecret } from "../api/atoms";4import { setSecret, resolveSecret } from "../api/atoms";
5import { secretWriteKeys } from "../api/reactivity-keys";
5import { useScope } from "../api/scope-context";6import { useScope } from "../api/scope-context";
6import { Button } from "../components/button";7import { Button } from "../components/button";
7import { Field, FieldError, FieldGroup, FieldLabel } from "../components/field";8import { Field, FieldError, FieldGroup, FieldLabel } from "../components/field";
@@ -73,7 +74,6 @@ function InlineCreateSecret(props: { · 65 unmodified lines
73 const [error, setError] = useState<string | null>(null);74 const [error, setError] = useState<string | null>(null);
74 const scopeId = useScope();75 const scopeId = useScope();
75 const doSet = useAtomSet(setSecret, { mode: "promise" });76 const doSet = useAtomSet(setSecret, { mode: "promise" });
76 const refreshSecrets = useAtomRefresh(secretsAtom(scopeId));
77 const secretIdInputId = useId();77 const secretIdInputId = useId();
78 const secretNameInputId = useId();78 const secretNameInputId = useId();
79 const secretValueInputId = useId();79 const secretValueInputId = useId();
@@ -90,8 +90,8 @@ function InlineCreateSecret(props: { · 10 unmodified lines
90 name: `${secretName.trim() || secretId.trim()} (Auth header: ${props.headerName})`,90 name: `${secretName.trim() || secretId.trim()} (Auth header: ${props.headerName})`,
91 value: secretValue.trim(),91 value: secretValue.trim(),
92 },92 },
93 reactivityKeys: secretWriteKeys,
93 });94 });
94 refreshSecrets();
95 props.onCreated(secretId.trim());95 props.onCreated(secretId.trim());
96 } catch (e) {96 } catch (e) {
97 setError(e instanceof Error ? e.message : "Failed to save secret");97 setError(e instanceof Error ? e.message : "Failed to save secret");
scripts/check-reactivity-keys.ts+124−0
@@ -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
14import { Glob } from "bun";
15
16const 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).
20const 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
32interface Violation {
33 readonly file: string;
34 readonly line: number;
35 readonly mutationVar: string;
36}
37
38const violations: Violation[] = [];
39
40const useAtomSetRegex = /useAtomSet\(\s*([A-Za-z_][\w]*)\s*,\s*\{\s*mode:\s*"promise"\s*\}\s*\)/g;
41
42for (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
109if (violations.length === 0) {
110 console.log("✓ reactivityKeys check passed");
111 process.exit(0);
112}
113
114console.error(`✗ reactivityKeys check failed — ${violations.length} mutation call(s) missing reactivityKeys:\n`);
115for (const v of violations) {
116 console.error(` ${v.file}:${v.line} — ${v.mutationVar}`);
117}
118console.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);
124process.exit(1);