History

feat(web): refresh source plugin screens and hot reload

Rhys Sullivan committed 7 months ago

Commit 81e63051 parent 85c17b117 files · +799 −943Browse files

Unified · Split

17 files
  1. apps/web/src/components/local-mcp-install-card.tsx +0 −1
  2. apps/web/vite.config.ts +15 −0
  3. packages/clients/react/src/components/secret-reference-field.tsx +3 −3
  4. packages/clients/react/src/plugins/index.ts +1 −0
  5. packages/clients/react/src/plugins/registry.ts +22 −0
  6. plugins/execution-history/react/index.ts +3 −2
  7. plugins/google-discovery/react/components.tsx +95 −70
  8. plugins/google-discovery/react/index.ts +7 −4
  9. plugins/graphql/react/components.tsx +170 −50
  10. plugins/graphql/react/index.ts +8 −5
  11. plugins/local-tools/react/components.tsx +45 −44
  12. plugins/local-tools/react/index.ts +6 −3
  13. plugins/mcp/react/components.tsx +263 −626
  14. plugins/mcp/react/index.ts +7 −4
  15. plugins/onepassword/react/index.ts +7 −2
  16. plugins/openapi/react/components.tsx +140 −125
  17. plugins/openapi/react/index.ts +7 −4
apps/web/src/components/local-mcp-install-card.tsx+0−1
@@ -1,7 +1,6 @@
1import { useEffect, useState } from "react";1import { useEffect, useState } from "react";
2import { Button } from "@executor/react/plugins";2import { Button } from "@executor/react/plugins";
3import { CodeBlock } from "./code-block";3import { CodeBlock } from "./code-block";
4import { cn } from "../lib/utils";
5 4
6type LocalMcpInstallMode = "stdio" | "http";5type LocalMcpInstallMode = "stdio" | "http";
7 6
apps/web/vite.config.ts+15−0
@@ -34,6 +34,20 @@ const workspaceExecutorPackages = Object.keys(webPackage.dependencies ?? {}).fil · 33 unmodified lines
34 (name) => name.startsWith("@executor/"),34 (name) => name.startsWith("@executor/"),
35);35);
36 36
37const pluginWorkspaceSegment = `${path.sep}plugins${path.sep}`;
38
39const reloadOnPluginChange = () => ({
40 name: "executor-reload-on-plugin-change",
41 handleHotUpdate({ file, server }: { file: string; server: { hot: { send: (payload: { type: "full-reload" }) => void } } }) {
42 if (!file.includes(pluginWorkspaceSegment)) {
43 return;
44 }
45
46 server.hot.send({ type: "full-reload" });
47 return [];
48 },
49});
50
37export default defineConfig({51export default defineConfig({
38 root: "src",52 root: "src",
39 resolve: {53 resolve: {
@@ -42,6 +56,7 @@ export default defineConfig({ · 2 unmodified lines
42 },56 },
43 },57 },
44 plugins: [58 plugins: [
59 reloadOnPluginChange(),
45 tailwindcss(),60 tailwindcss(),
46 react(),61 react(),
47 devServer({62 devServer({
packages/clients/react/src/components/secret-reference-field.tsx+3−3
@@ -80,9 +80,9 @@ export function SecretReferenceField(props: SecretReferenceFieldProps) { · 79 unmodified lines
80 path: {80 path: {
81 storeId: input.storeId,81 storeId: input.storeId,
82 },82 },
83 payload: {83 payload: input.query?.trim()
84 ...(input.query?.trim() ? { query: input.query.trim() } : {}),84 ? { query: input.query.trim() }
85 },85 : {},
86 }),86 }),
87 [browseSecretStoreMutation],87 [browseSecretStoreMutation],
88 );88 );
packages/clients/react/src/plugins/index.ts+1−0
@@ -35,6 +35,7 @@ export { · 34 unmodified lines
35export {35export {
36 defineExecutorFrontendPlugin,36 defineExecutorFrontendPlugin,
37 defineFrontendPluginRoute,37 defineFrontendPluginRoute,
38 liveFrontendPluginComponent,
38 registerExecutorFrontendPlugins,39 registerExecutorFrontendPlugins,
39 type RegisteredFrontendPluginRoute,40 type RegisteredFrontendPluginRoute,
40} from "./registry";41} from "./registry";
packages/clients/react/src/plugins/registry.ts+22−0
@@ -1,3 +1,7 @@
1import {
2 createElement,
3 type ComponentType,
4} from "react";
1import type {5import type {
2 ExecutorFrontendPlugin,6 ExecutorFrontendPlugin,
3 FrontendPluginRouteDefinition,7 FrontendPluginRouteDefinition,
@@ -23,6 +27,24 @@ export const defineExecutorFrontendPlugin = < · 19 unmodified lines
23 input: TPlugin,27 input: TPlugin,
24): TPlugin => input;28): TPlugin => input;
25 29
30// Plugin objects are registered once at module load, so route components need
31// to resolve lazily for React Fast Refresh to see edits inside plugin packages.
32export const liveFrontendPluginComponent = <
33 TProps extends object,
34>(
35 resolveComponent: () => ComponentType<TProps>,
36): ComponentType<TProps> => {
37 const resolvedComponent = resolveComponent();
38
39 const LiveFrontendPluginComponent = (props: TProps) =>
40 createElement(resolveComponent(), props);
41
42 LiveFrontendPluginComponent.displayName =
43 `LiveFrontendPluginComponent(${resolvedComponent.displayName ?? resolvedComponent.name ?? "Anonymous"})`;
44
45 return LiveFrontendPluginComponent;
46};
47
26export const registerExecutorFrontendPlugins = (48export const registerExecutorFrontendPlugins = (
27 plugins: readonly ExecutorFrontendPlugin[],49 plugins: readonly ExecutorFrontendPlugin[],
28) => {50) => {
plugins/execution-history/react/index.ts+3−2
@@ -1,5 +1,6 @@
1import {1import {
2 defineExecutorFrontendPlugin,2 defineExecutorFrontendPlugin,
3 liveFrontendPluginComponent,
3} from "@executor/react/plugins";4} from "@executor/react/plugins";
4 5
5import {6import {
@@ -14,7 +15,7 @@ export const ExecutionHistoryReactPlugin = defineExecutorFrontendPlugin({ · 8 unmodified lines
14 routes: [15 routes: [
15 {16 {
16 key: "history",17 key: "history",
17 component: ExecutionHistoryPage,18 component: liveFrontendPluginComponent(() => ExecutionHistoryPage),
18 nav: {19 nav: {
19 label: "Runs",20 label: "Runs",
20 section: "main",21 section: "main",
@@ -23,7 +24,7 @@ export const ExecutionHistoryReactPlugin = defineExecutorFrontendPlugin({ · 2 unmodified lines
23 {24 {
24 key: "detail",25 key: "detail",
25 path: "$executionId",26 path: "$executionId",
26 component: ExecutionHistoryDetailPage,27 component: liveFrontendPluginComponent(() => ExecutionHistoryDetailPage),
27 },28 },
28 ],29 ],
29});30});
plugins/google-discovery/react/components.tsx+95−70
@@ -14,7 +14,6 @@ import { · 13 unmodified lines
14 Alert,14 Alert,
15 Badge,15 Badge,
16 Button,16 Button,
17 Card,
18 IconPencil,17 IconPencil,
19 Input,18 Input,
20 Label,19 Label,
@@ -573,7 +572,7 @@ function GoogleDiscoverySourceForm(props: { · 552 unmodified lines
573 };572 };
574 573
575 return (574 return (
576 <Card className="space-y-6 p-6">575 <div className="space-y-6 rounded-lg border border-border bg-card p-6 text-sm ring-1 ring-foreground/[0.04]">
577 <div className="grid gap-4 sm:grid-cols-2">576 <div className="grid gap-4 sm:grid-cols-2">
578 <div className="grid gap-2 sm:col-span-2">577 <div className="grid gap-2 sm:col-span-2">
579 <Label>Name</Label>578 <Label>Name</Label>
@@ -753,7 +752,7 @@ function GoogleDiscoverySourceForm(props: { · 173 unmodified lines
753 : props.mode === "create" ? "Create Source" : "Save Changes"}752 : props.mode === "create" ? "Create Source" : "Save Changes"}
754 </Button>753 </Button>
755 </div>754 </div>
756 </Card>755 </div>
757 );756 );
758}757}
759 758
@@ -857,7 +856,7 @@ function GoogleDiscoveryBatchConnectPanel(props: { · 97 unmodified lines
857 };856 };
858 857
859 return (858 return (
860 <Card className="space-y-6 p-6">859 <div className="space-y-6 rounded-lg border border-border bg-card p-6 text-sm ring-1 ring-foreground/[0.04]">
861 <div>860 <div>
862 <h2 className="text-base font-semibold text-foreground">861 <h2 className="text-base font-semibold text-foreground">
863 Connect multiple Google APIs862 Connect multiple Google APIs
@@ -1008,7 +1007,7 @@ function GoogleDiscoveryBatchConnectPanel(props: { · 144 unmodified lines
1008 : "Connect Google APIs"}1007 : "Connect Google APIs"}
1009 </Button>1008 </Button>
1010 </div>1009 </div>
1011 </Card>1010 </div>
1012 );1011 );
1013}1012}
1014 1013
@@ -1027,51 +1026,64 @@ export function GoogleDiscoveryAddPage() { · 12 unmodified lines
1027 }1026 }
1028 1027
1029 return (1028 return (
1030 <div className="space-y-6">1029 <div className="min-h-0 flex-1 overflow-y-auto">
1031 <GoogleDiscoveryBatchConnectPanel1030 <div className="mx-auto w-full max-w-3xl px-6 py-10">
1032 onConnected={(sources) => {1031 <div className="mb-8">
1033 startTransition(() => {1032 <h1 className="font-display text-2xl tracking-tight text-foreground">
1034 if (sources.length === 1) {1033 Add Google API Source
1035 void navigation.detail(sources[0]!.id, {1034 </h1>
1036 tab: "model",1035 <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
1036 Connect Google Workspace and Cloud APIs via discovery documents.
1037 </p>
1038 </div>
1039
1040 <div className="space-y-6">
1041 <GoogleDiscoveryBatchConnectPanel
1042 onConnected={(sources) => {
1043 startTransition(() => {
1044 if (sources.length === 1) {
1045 void navigation.detail(sources[0]!.id, {
1046 tab: "model",
1047 });
1048 return;
1049 }
1050
1051 void navigation.home();
1037 });1052 });
1038 return;1053 }}
1039 }1054 />
1040 1055
1041 void navigation.home();1056 <div className="flex items-center gap-3">
1042 });1057 <Separator className="flex-1" />
1043 }}1058 <div className="text-xs font-medium text-muted-foreground">
1044 />1059 or add a single API
1060 </div>
1061 <Separator className="flex-1" />
1062 </div>
1045 1063
1046 <div className="flex items-center gap-3">1064 <GoogleDiscoverySourceForm
1047 <Separator className="flex-1" />1065 initialValue={initialValue}
1048 <div className="text-xs font-medium text-muted-foreground">1066 mode="create"
1049 or add a single API1067 onSubmit={async (input) => {
1068 const source = await createSource({
1069 path: {
1070 workspaceId: installation.data.scopeId,
1071 },
1072 payload: input,
1073 reactivityKeys: {
1074 sources: [installation.data.scopeId],
1075 },
1076 });
1077
1078 startTransition(() => {
1079 void navigation.detail(source.id, {
1080 tab: "model",
1081 });
1082 });
1083 }}
1084 />
1050 </div>1085 </div>
1051 <Separator className="flex-1" />
1052 </div>1086 </div>
1053
1054 <GoogleDiscoverySourceForm
1055 initialValue={initialValue}
1056 mode="create"
1057 onSubmit={async (input) => {
1058 const source = await createSource({
1059 path: {
1060 workspaceId: installation.data.scopeId,
1061 },
1062 payload: input,
1063 reactivityKeys: {
1064 sources: [installation.data.scopeId],
1065 },
1066 });
1067
1068 startTransition(() => {
1069 void navigation.detail(source.id, {
1070 tab: "model",
1071 });
1072 });
1073 }}
1074 />
1075 </div>1087 </div>
1076 );1088 );
1077}1089}
@@ -1120,32 +1132,45 @@ export function GoogleDiscoveryEditPage(props: { · 42 unmodified lines
1120 }1132 }
1121 1133
1122 return (1134 return (
1123 <GoogleDiscoverySourceForm1135 <div className="min-h-0 flex-1 overflow-y-auto">
1124 initialValue={configResult.value}1136 <div className="mx-auto w-full max-w-3xl px-6 py-10">
1125 mode="edit"1137 <div className="mb-8">
1126 onSubmit={async (input) => {1138 <h1 className="font-display text-2xl tracking-tight text-foreground">
1127 const source = await updateSource({1139 Edit Google API Source
1128 path: {1140 </h1>
1129 workspaceId: installation.data.scopeId,1141 <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
1130 sourceId: props.source.id,1142 Update the connection settings for {props.source.name}.
1131 },1143 </p>
1132 payload: input,1144 </div>
1133 reactivityKeys: {
1134 sources: [installation.data.scopeId],
1135 source: [installation.data.scopeId, props.source.id],
1136 sourceInspection: [installation.data.scopeId, props.source.id],
1137 sourceInspectionTool: [installation.data.scopeId, props.source.id],
1138 sourceDiscovery: [installation.data.scopeId, props.source.id],
1139 },
1140 });
1141 1145
1142 startTransition(() => {1146 <GoogleDiscoverySourceForm
1143 void navigation.detail(source.id, {1147 initialValue={configResult.value}
1144 tab: "model",1148 mode="edit"
1145 });1149 onSubmit={async (input) => {
1146 });1150 const source = await updateSource({
1147 }}1151 path: {
1148 />1152 workspaceId: installation.data.scopeId,
1153 sourceId: props.source.id,
1154 },
1155 payload: input,
1156 reactivityKeys: {
1157 sources: [installation.data.scopeId],
1158 source: [installation.data.scopeId, props.source.id],
1159 sourceInspection: [installation.data.scopeId, props.source.id],
1160 sourceInspectionTool: [installation.data.scopeId, props.source.id],
1161 sourceDiscovery: [installation.data.scopeId, props.source.id],
1162 },
1163 });
1164
1165 startTransition(() => {
1166 void navigation.detail(source.id, {
1167 tab: "model",
1168 });
1169 });
1170 }}
1171 />
1172 </div>
1173 </div>
1149 );1174 );
1150}1175}
1151 1176
plugins/google-discovery/react/index.ts+7−4
@@ -1,4 +1,7 @@
1import { defineExecutorFrontendPlugin } from "@executor/react/plugins";1import {
2 defineExecutorFrontendPlugin,
3 liveFrontendPluginComponent,
4} from "@executor/react/plugins";
2 5
3import {6import {
4 GOOGLE_DISCOVERY_PLUGIN_KEY,7 GOOGLE_DISCOVERY_PLUGIN_KEY,
@@ -22,17 +25,17 @@ export const GoogleDiscoveryReactPlugin = defineExecutorFrontendPlugin({ · 17 unmodified lines
22 {25 {
23 key: "add",26 key: "add",
24 path: "add",27 path: "add",
25 component: GoogleDiscoveryAddPage,28 component: liveFrontendPluginComponent(() => GoogleDiscoveryAddPage),
26 },29 },
27 {30 {
28 key: "detail",31 key: "detail",
29 path: "sources/$sourceId",32 path: "sources/$sourceId",
30 component: GoogleDiscoveryDetailRoute,33 component: liveFrontendPluginComponent(() => GoogleDiscoveryDetailRoute),
31 },34 },
32 {35 {
33 key: "edit",36 key: "edit",
34 path: "sources/$sourceId/edit",37 path: "sources/$sourceId/edit",
35 component: GoogleDiscoveryEditRoute,38 component: liveFrontendPluginComponent(() => GoogleDiscoveryEditRoute),
36 },39 },
37 ],40 ],
38});41});
plugins/graphql/react/components.tsx+170−50
@@ -214,7 +214,7 @@ function GraphqlSourceForm(props: { · 213 unmodified lines
214 const [error, setError] = useState<string | null>(null);214 const [error, setError] = useState<string | null>(null);
215 215
216 return (216 return (
217 <Card className="space-y-6 p-6">217 <div className="space-y-6 rounded-lg border border-border bg-card p-6 text-sm ring-1 ring-foreground/[0.04]">
218 <div className="grid gap-4">218 <div className="grid gap-4">
219 <div className="grid gap-2">219 <div className="grid gap-2">
220 <Label>Name</Label>220 <Label>Name</Label>
@@ -327,7 +327,7 @@ function GraphqlSourceForm(props: { · 106 unmodified lines
327 : props.mode === "create" ? "Create Source" : "Save Changes"}327 : props.mode === "create" ? "Create Source" : "Save Changes"}
328 </Button>328 </Button>
329 </div>329 </div>
330 </Card>330 </div>
331 );331 );
332}332}
333 333
@@ -341,32 +341,46 @@ export function GraphqlAddPage() { · 7 unmodified lines
341 { mode: "promise" },341 { mode: "promise" },
342 );342 );
343 343
344
344 if (installation.status !== "ready") {345 if (installation.status !== "ready") {
345 return <div className="text-sm text-muted-foreground">Loading workspace...</div>;346 return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
346 }347 }
347 348
348 return (349 return (
349 <GraphqlSourceForm350 <div className="min-h-0 flex-1 overflow-y-auto">
350 initialValue={initialValue}351 <div className="mx-auto w-full max-w-3xl px-6 py-10">
351 mode="create"352 <div className="mb-8">
352 onSubmit={async (input) => {353 <h1 className="font-display text-2xl tracking-tight text-foreground">
353 const source = await createSource({354 Add GraphQL Source
354 path: {355 </h1>
355 workspaceId: installation.data.scopeId,356 <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
356 },357 Connect a GraphQL endpoint and expose its operations as tools.
357 payload: input,358 </p>
358 reactivityKeys: {359 </div>
359 sources: [installation.data.scopeId],360
360 },361 <GraphqlSourceForm
361 });362 initialValue={initialValue}
362 363 mode="create"
363 startTransition(() => {364 onSubmit={async (input) => {
364 void navigation.detail(source.id, {365 const source = await createSource({
365 tab: "model",366 path: {
366 });367 workspaceId: installation.data.scopeId,
367 });368 },
368 }}369 payload: input,
369 />370 reactivityKeys: {
371 sources: [installation.data.scopeId],
372 },
373 });
374
375 startTransition(() => {
376 void navigation.detail(source.id, {
377 tab: "model",
378 });
379 });
380 }}
381 />
382 </div>
383 </div>
370 );384 );
371}385}
372 386
@@ -414,32 +428,45 @@ export function GraphqlEditPage(props: { · 41 unmodified lines
414 }428 }
415 429
416 return (430 return (
417 <GraphqlSourceForm431 <div className="min-h-0 flex-1 overflow-y-auto">
418 initialValue={configResult.value}432 <div className="mx-auto w-full max-w-3xl px-6 py-10">
419 mode="edit"433 <div className="mb-8">
420 onSubmit={async (input) => {434 <h1 className="font-display text-2xl tracking-tight text-foreground">
421 const source = await updateSource({435 Edit GraphQL Source
422 path: {436 </h1>
423 workspaceId: installation.data.scopeId,437 <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
424 sourceId: props.source.id,438 Update the connection settings for {props.source.name}.
425 },439 </p>
426 payload: input,440 </div>
427 reactivityKeys: {441
428 sources: [installation.data.scopeId],442 <GraphqlSourceForm
429 source: [installation.data.scopeId, props.source.id],443 initialValue={configResult.value}
430 sourceInspection: [installation.data.scopeId, props.source.id],444 mode="edit"
431 sourceInspectionTool: [installation.data.scopeId, props.source.id],445 onSubmit={async (input) => {
432 sourceDiscovery: [installation.data.scopeId, props.source.id],446 const source = await updateSource({
433 },447 path: {
434 });448 workspaceId: installation.data.scopeId,
435 449 sourceId: props.source.id,
436 startTransition(() => {450 },
437 void navigation.detail(source.id, {451 payload: input,
438 tab: "model",452 reactivityKeys: {
439 });453 sources: [installation.data.scopeId],
440 });454 source: [installation.data.scopeId, props.source.id],
441 }}455 sourceInspection: [installation.data.scopeId, props.source.id],
442 />456 sourceInspectionTool: [installation.data.scopeId, props.source.id],
457 sourceDiscovery: [installation.data.scopeId, props.source.id],
458 },
459 });
460
461 startTransition(() => {
462 void navigation.detail(source.id, {
463 tab: "model",
464 });
465 });
466 }}
467 />
468 </div>
469 </div>
443 );470 );
444}471}
445 472
@@ -542,6 +569,91 @@ function GraphqlDetailExplorer(props: { · 96 unmodified lines
542 };569 };
543 570
544 const config = Result.isSuccess(configResult) ? configResult.value : null;571 const config = Result.isSuccess(configResult) ? configResult.value : null;
572 const sourceNeedsRecovery =
573 props.source.status === "auth_required" || props.source.status === "error";
574
575 if (sourceNeedsRecovery) {
576 const title =
577 props.source.status === "auth_required"
578 ? "Credentials required"
579 : "Source needs attention";
580 const description =
581 props.source.status === "auth_required"
582 ? "This endpoint requires credentials before its schema can be introspected. Edit the source, configure bearer auth, and save again."
583 : "This source could not be indexed successfully. Edit the configuration and save again to rebuild its catalog.";
584
585 return (
586 <div className="min-h-0 flex-1 overflow-y-auto">
587 <div className="mx-auto w-full max-w-3xl px-6 py-10">
588 <Card className="space-y-6 p-6">
589 <div className="space-y-2">
590 <div className="flex items-center gap-2">
591 <h1 className="font-display text-2xl tracking-tight text-foreground">
592 {title}
593 </h1>
594 <Badge variant="outline">{props.source.status}</Badge>
595 </div>
596 <p className="text-sm leading-6 text-muted-foreground">
597 {description}
598 </p>
599 </div>
600
601 {config ? (
602 <div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
603 <span className="font-mono text-foreground">{config.endpoint}</span>
604 <Badge variant="muted">Auth: {config.auth.kind}</Badge>
605 </div>
606 ) : null}
607
608 <div className="flex flex-wrap items-center gap-2">
609 <Button
610 variant="outline"
611 size="sm"
612 onClick={() => void navigation.edit(props.source.id)}
613 >
614 <IconPencil className="size-3" />
615 Edit
616 </Button>
617 {confirmDelete ? (
618 <>
619 <span className="text-[11px] font-medium text-destructive">
620 Confirm delete?
621 </span>
622 <Button
623 variant="outline"
624 size="sm"
625 onClick={() => setConfirmDelete(false)}
626 disabled={isDeleting}
627 >
628 Cancel
629 </Button>
630 <Button
631 variant="destructive-outline"
632 size="sm"
633 onClick={() => {
634 void handleDelete().catch(() => {});
635 }}
636 disabled={isDeleting}
637 >
638 {isDeleting ? "Deleting..." : "Delete"}
639 </Button>
640 </>
641 ) : (
642 <Button
643 variant="destructive-outline"
644 size="sm"
645 onClick={() => setConfirmDelete(true)}
646 disabled={isDeleting}
647 >
648 Delete
649 </Button>
650 )}
651 </div>
652 </Card>
653 </div>
654 </div>
655 );
656 }
545 657
546 return (658 return (
547 <SourceToolExplorer659 <SourceToolExplorer
@@ -661,7 +773,7 @@ function GraphqlSourceRoute(props: { · 113 unmodified lines
661 const sourceId = typeof params.sourceId === "string" ? params.sourceId : null;773 const sourceId = typeof params.sourceId === "string" ? params.sourceId : null;
662 const source = useSource(sourceId ?? "");774 const source = useSource(sourceId ?? "");
663 775
664 if (sourceId === null || source.status === "error") {776 if (sourceId === null) {
665 return (777 return (
666 <div className="px-6 py-8 text-sm text-destructive">778 <div className="px-6 py-8 text-sm text-destructive">
667 This GraphQL source is unavailable.779 This GraphQL source is unavailable.
@@ -677,6 +789,14 @@ function GraphqlSourceRoute(props: { · 9 unmodified lines
677 );789 );
678 }790 }
679 791
792 if (source.status === "error") {
793 return (
794 <div className="px-6 py-8 text-sm text-destructive">
795 Failed loading this GraphQL source.
796 </div>
797 );
798 }
799
680 if (source.data.kind !== "graphql") {800 if (source.data.kind !== "graphql") {
681 return (801 return (
682 <div className="px-6 py-8 text-sm text-destructive">802 <div className="px-6 py-8 text-sm text-destructive">
plugins/graphql/react/index.ts+8−5
@@ -1,4 +1,7 @@
1import { defineExecutorFrontendPlugin } from "@executor/react/plugins";1import {
2 defineExecutorFrontendPlugin,
3 liveFrontendPluginComponent,
4} from "@executor/react/plugins";
2 5
3import {6import {
4 GraphqlAddPage,7 GraphqlAddPage,
@@ -15,22 +18,22 @@ export const GraphqlReactPlugin = defineExecutorFrontendPlugin({ · 10 unmodified lines
15 {18 {
16 key: "add",19 key: "add",
17 path: "add",20 path: "add",
18 component: GraphqlAddPage,21 component: liveFrontendPluginComponent(() => GraphqlAddPage),
19 },22 },
20 {23 {
21 key: "detail",24 key: "detail",
22 path: "sources/$sourceId",25 path: "sources/$sourceId",
23 component: GraphqlDetailRoute,26 component: liveFrontendPluginComponent(() => GraphqlDetailRoute),
24 },27 },
25 {28 {
26 key: "edit",29 key: "edit",
27 path: "sources/$sourceId/edit",30 path: "sources/$sourceId/edit",
28 component: GraphqlEditRoute,31 component: liveFrontendPluginComponent(() => GraphqlEditRoute),
29 },32 },
30 {33 {
31 key: "tool-detail",34 key: "tool-detail",
32 path: "sources/$sourceId/tool/$toolPath",35 path: "sources/$sourceId/tool/$toolPath",
33 component: GraphqlToolDetailRoute,36 component: liveFrontendPluginComponent(() => GraphqlToolDetailRoute),
34 },37 },
35 ],38 ],
36});39});
plugins/local-tools/react/components.tsx+45−44
@@ -9,7 +9,6 @@ import { · 8 unmodified lines
9import {9import {
10 Alert,10 Alert,
11 Button,11 Button,
12 Card,
13 SourceToolExplorer,12 SourceToolExplorer,
14 parseSourceToolExplorerSearch,13 parseSourceToolExplorerSearch,
15 useSourcePluginNavigation,14 useSourcePluginNavigation,
@@ -118,52 +117,54 @@ export function LocalToolsAddPage() { · 102 unmodified lines
118 ) ?? null;117 ) ?? null;
119 118
120 return (119 return (
121 <div className="mx-auto w-full max-w-3xl px-6 py-10">120 <div className="min-h-0 flex-1 overflow-y-auto">
122 <Card className="p-6">121 <div className="mx-auto w-full max-w-3xl px-6 py-10">
123 <h1 className="font-display text-2xl tracking-tight text-foreground">122 <div className="mb-8">
124 Local Tools123 <h1 className="font-display text-2xl tracking-tight text-foreground">
125 </h1>124 Local Tools
126 <p className="mt-2 text-sm leading-6 text-muted-foreground">125 </h1>
127 Promote file-backed tools in <code className="rounded bg-muted px-1 py-0.5 font-mono">.executor/tools</code>126 <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
128 {" "}into a first-class source with inspection and discovery.127 File-backed tools from <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">.executor/tools</code> promoted into a first-class source.
129 </p>
130
131 <Card className="mt-6 bg-background/60 p-4 text-sm text-muted-foreground">
132 <p>
133 The source is provisioned automatically when the executor session starts and finds
134 local tool files in <code className="rounded bg-muted px-1 py-0.5 font-mono">.executor/tools</code>.
135 </p>128 </p>
136 <p className="mt-3">129 </div>
137 Existing tool path behavior is preserved, so a file like130
138 {" "}<code className="rounded bg-muted px-1 py-0.5 font-mono">.executor/tools/demo.ts</code>131 <div className="space-y-6 rounded-lg border border-border bg-card p-6 text-sm ring-1 ring-foreground/[0.04]">
139 {" "}still appears as <code className="rounded bg-muted px-1 py-0.5 font-mono">tools.demo(...)</code>.132 <div className="rounded-lg border border-border bg-muted/20 p-4 text-sm leading-6 text-muted-foreground">
140 </p>133 <p>
141 </Card>134 The source is provisioned automatically when the executor session starts and finds
142 135 local tool files in <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">.executor/tools</code>.
143 <div className="mt-6 flex flex-wrap items-center gap-3">136 </p>
144 {localToolsSource ? (137 <p className="mt-3">
145 <Button138 Existing tool path behavior is preserved, so a file like
146 size="lg"139 {" "}<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">.executor/tools/demo.ts</code>
147 type="button"140 {" "}still appears as <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">tools.demo(...)</code>.
148 onClick={() => {141 </p>
149 startTransition(() => {142 </div>
150 void navigation.detail(localToolsSource.id, {143
151 tab: "model",144 <div className="flex flex-wrap items-center gap-3">
145 {localToolsSource ? (
146 <Button
147 type="button"
148 onClick={() => {
149 startTransition(() => {
150 void navigation.detail(localToolsSource.id, {
151 tab: "model",
152 });
152 });153 });
153 });154 }}
154 }}155 >
155 >156 Open Local Tools Source
156 Open Local Tools Source157 </Button>
157 </Button>158 ) : (
158 ) : (159 <Alert variant="warning">
159 <Alert variant="warning">160 No local tools source is active yet. Create a tool in
160 No local tools source is active yet. Create a tool in161 {" "}<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">.executor/tools</code>
161 {" "}<code className="rounded bg-muted px-1 py-0.5 font-mono">.executor/tools</code>162 {" "}and restart the executor session.
162 {" "}and restart the executor session.163 </Alert>
163 </Alert>164 )}
164 )}165 </div>
165 </div>166 </div>
166 </Card>167 </div>
167 </div>168 </div>
168 );169 );
169}170}
plugins/local-tools/react/index.ts+6−3
@@ -1,4 +1,7 @@
1import { defineExecutorFrontendPlugin } from "@executor/react/plugins";1import {
2 defineExecutorFrontendPlugin,
3 liveFrontendPluginComponent,
4} from "@executor/react/plugins";
2 5
3import {6import {
4 LocalToolsAddPage,7 LocalToolsAddPage,
@@ -13,12 +16,12 @@ export const LocalToolsReactPlugin = defineExecutorFrontendPlugin({ · 8 unmodified lines
13 {16 {
14 key: "add",17 key: "add",
15 path: "add",18 path: "add",
16 component: LocalToolsAddPage,19 component: liveFrontendPluginComponent(() => LocalToolsAddPage),
17 },20 },
18 {21 {
19 key: "detail",22 key: "detail",
20 path: "sources/$sourceId",23 path: "sources/$sourceId",
21 component: LocalToolsDetailRoute,24 component: liveFrontendPluginComponent(() => LocalToolsDetailRoute),
22 },25 },
23 ],26 ],
24});27});
plugins/mcp/react/components.tsx+263−626
@@ -13,12 +13,7 @@ import { · 12 unmodified lines
13 Alert,13 Alert,
14 Badge,14 Badge,
15 Button,15 Button,
16 Card,
17 cn,
18 IconCheck,
19 IconPencil,16 IconPencil,
20 IconSearch,
21 IconSpinner,
22 Input,17 Input,
23 Label,18 Label,
24 Select,19 Select,
@@ -36,8 +31,6 @@ import { · 11 unmodified lines
36} from "@executor/plugin-mcp-http";31} from "@executor/plugin-mcp-http";
37import {32import {
38 type McpConnectInput,33 type McpConnectInput,
39 type McpDiscoverInput,
40 type McpDiscoverResult,
41 type McpConnectionAuth,34 type McpConnectionAuth,
42 type McpOAuthPopupResult,35 type McpOAuthPopupResult,
43 type McpStartOAuthInput,36 type McpStartOAuthInput,
@@ -78,82 +71,6 @@ const defaultMcpInput = (): McpConnectInput => ({ · 34 unmodified lines
78 },71 },
79});72});
80 73
81type McpQuickPreset = {
82 id: string;
83 name: string;
84 summary: string;
85 input: McpConnectInput;
86};
87
88type ProbeAuthState = {
89 kind: NonNullable<McpDiscoverInput["probeAuth"]>["kind"];
90 token: string;
91 headerName: string;
92 prefix: string;
93 username: string;
94 password: string;
95 headersText: string;
96};
97
98const defaultProbeAuthState = (): ProbeAuthState => ({
99 kind: "none",
100 token: "",
101 headerName: "Authorization",
102 prefix: "Bearer ",
103 username: "",
104 password: "",
105 headersText: "",
106});
107
108const mcpQuickPresets: ReadonlyArray<McpQuickPreset> = [
109 {
110 id: "deepwiki-mcp",
111 name: "DeepWiki MCP",
112 summary: "Repository docs and knowledge graphs via a remote MCP endpoint.",
113 input: {
114 ...defaultMcpInput(),
115 name: "DeepWiki MCP",
116 endpoint: "https://mcp.deepwiki.com/mcp",
117 transport: "auto",
118 },
119 },
120 {
121 id: "axiom-mcp",
122 name: "Axiom MCP",
123 summary: "Query and analyze logs and traces through Axiom's MCP server.",
124 input: {
125 ...defaultMcpInput(),
126 name: "Axiom MCP",
127 endpoint: "https://mcp.axiom.co/mcp",
128 transport: "auto",
129 },
130 },
131 {
132 id: "neon-mcp",
133 name: "Neon MCP",
134 summary: "Manage databases, branches, and queries via Neon MCP.",
135 input: {
136 ...defaultMcpInput(),
137 name: "Neon MCP",
138 endpoint: "https://mcp.neon.tech/mcp",
139 transport: "auto",
140 },
141 },
142 {
143 id: "chrome-devtools-mcp",
144 name: "Chrome DevTools MCP",
145 summary: "Launch the local Chrome DevTools MCP server over stdio.",
146 input: {
147 ...defaultMcpInput(),
148 name: "Chrome DevTools MCP",
149 endpoint: null,
150 transport: "stdio",
151 command: "npx",
152 args: ["-y", "chrome-devtools-mcp@latest"],
153 },
154 },
155];
156
157const presetString = (74const presetString = (
158 search: Record<string, unknown>,75 search: Record<string, unknown>,
159 key: string,76 key: string,
@@ -250,49 +167,6 @@ const transportFieldsFromInput = (input: McpConnectInput): McpTransportFields => · 90 unmodified lines
250 headersText: stringifyStringMap(input.headers),167 headersText: stringifyStringMap(input.headers),
251 };168 };
252 169
253const buildProbeAuth = (
254 state: ProbeAuthState,
255): McpDiscoverInput["probeAuth"] => {
256 if (state.kind === "none") {
257 return { kind: "none" };
258 }
259
260 if (state.kind === "bearer") {
261 if (!state.token.trim()) {
262 throw new Error("Token is required for bearer discovery auth.");
263 }
264
265 return {
266 kind: "bearer",
267 headerName: state.headerName.trim() || null,
268 prefix: state.prefix.length > 0 ? state.prefix : null,
269 token: state.token.trim(),
270 };
271 }
272
273 if (state.kind === "basic") {
274 if (!state.username.trim()) {
275 throw new Error("Username is required for basic discovery auth.");
276 }
277
278 return {
279 kind: "basic",
280 username: state.username.trim(),
281 password: state.password,
282 };
283 }
284
285 const headers = parseJsonStringMap("Discovery headers", state.headersText);
286 if (!headers) {
287 throw new Error("At least one discovery header is required.");
288 }
289
290 return {
291 kind: "headers",
292 headers,
293 };
294};
295
296const waitForOauthPopupResult = async (170const waitForOauthPopupResult = async (
297 sessionId: string,171 sessionId: string,
298): Promise<McpOAuthPopupResult> =>172): Promise<McpOAuthPopupResult> =>
@@ -387,10 +261,6 @@ function McpSourceForm(props: { · 88 unmodified lines
387}) {261}) {
388 const installation = useLocalInstallation();262 const installation = useLocalInstallation();
389 const client = getMcpHttpClient();263 const client = getMcpHttpClient();
390 const discoverSource = useAtomSet(
391 client.mutation("mcp", "discoverSource"),
392 { mode: "promise" },
393 );
394 const startOAuth = useAtomSet(264 const startOAuth = useAtomSet(
395 client.mutation("mcp", "startOAuth"),265 client.mutation("mcp", "startOAuth"),
396 { mode: "promise" },266 { mode: "promise" },
@@ -415,104 +285,9 @@ function McpSourceForm(props: { · 18 unmodified lines
415 props.initialValue.auth.kind === "oauth2" ? "connected" : "idle",285 props.initialValue.auth.kind === "oauth2" ? "connected" : "idle",
416 );286 );
417 const [error, setError] = useState<string | null>(null);287 const [error, setError] = useState<string | null>(null);
418 const [discoveryEndpoint, setDiscoveryEndpoint] = useState(
419 props.initialValue.command ? "" : (props.initialValue.endpoint ?? ""),
420 );
421 const [showProbeAuth, setShowProbeAuth] = useState(false);
422 const [probeAuth, setProbeAuth] = useState<ProbeAuthState>(defaultProbeAuthState);
423 const [discoveryMessage, setDiscoveryMessage] = useState<string | null>(null);
424 const discoverMutation = useExecutorMutation<McpDiscoverInput, McpDiscoverResult>(
425 async (input) => {
426 if (installation.status !== "ready") {
427 throw new Error("Workspace is still loading.");
428 }
429
430 return discoverSource({
431 path: {
432 workspaceId: installation.data.scopeId,
433 },
434 payload: input,
435 });
436 },
437 );
438 288
439 const isStdio = transportFields.transport === "stdio";289 const isStdio = transportFields.transport === "stdio";
440 290
441 const applyPreset = (preset: McpQuickPreset) => {
442 setName(preset.input.name);
443 setEndpoint(preset.input.endpoint ?? "");
444 setTransportFields(transportFieldsFromInput(preset.input));
445 setAuthKind(preset.input.auth.kind);
446 setOauthAuth(preset.input.auth.kind === "oauth2" ? preset.input.auth : null);
447 setOauthStatus(preset.input.auth.kind === "oauth2" ? "connected" : "idle");
448 setDiscoveryEndpoint(preset.input.endpoint ?? "");
449 setDiscoveryMessage(`Loaded ${preset.name}.`);
450 setError(null);
451 };
452
453 const applyDiscoveredRemoteSource = (result: NonNullable<McpDiscoverResult>) => {
454 const remoteTransport =
455 result.transport === "streamable-http" ||
456 result.transport === "sse" ||
457 result.transport === "auto"
458 ? result.transport
459 : "auto";
460
461 setName(result.name?.trim() || name);
462 setEndpoint(result.endpoint);
463 setTransportFields({
464 ...defaultMcpRemoteTransportFields(remoteTransport),
465 queryParamsText:
466 transportFields.transport === "stdio" ? "" : transportFields.queryParamsText,
467 headersText:
468 transportFields.transport === "stdio" ? "" : transportFields.headersText,
469 });
470 if (result.authInference.supported && result.authInference.suggestedKind === "oauth2") {
471 setAuthKind("oauth2");
472 setDiscoveryMessage(
473 result.warnings[0]
474 ?? "The server advertised OAuth during discovery. Connect OAuth before saving.",
475 );
476 } else {
477 setAuthKind("none");
478 setOauthAuth(null);
479 setOauthStatus("idle");
480 setDiscoveryMessage(
481 result.warnings[0]
482 ?? `Discovered ${result.toolCount ?? "unknown"} MCP tools and prefilled the connection.`,
483 );
484 }
485 setError(null);
486 };
487
488 const handleDiscover = async () => {
489 if (installation.status !== "ready") {
490 setError("Workspace is still loading.");
491 return;
492 }
493
494 setError(null);
495 setDiscoveryMessage(null);
496
497 try {
498 const result = await discoverMutation.mutateAsync({
499 endpoint: discoveryEndpoint.trim(),
500 probeAuth: showProbeAuth ? buildProbeAuth(probeAuth) : { kind: "none" },
501 });
502
503 if (result === null) {
504 setDiscoveryMessage(
505 "Could not verify this endpoint as MCP. You can still continue with manual setup.",
506 );
507 return;
508 }
509
510 applyDiscoveredRemoteSource(result);
511 } catch (cause) {
512 setError(cause instanceof Error ? cause.message : String(cause));
513 }
514 };
515
516 const runOauth = async () => {291 const runOauth = async () => {
517 if (installation.status !== "ready") {292 if (installation.status !== "ready") {
518 throw new Error("Workspace is still loading.");293 throw new Error("Workspace is still loading.");
@@ -591,382 +366,218 @@ function McpSourceForm(props: { · 72 unmodified lines
591 366
592 return (367 return (
593 <div className="space-y-8">368 <div className="space-y-8">
594 {props.mode === "create" && (369 <div className="space-y-6 rounded-lg border border-border bg-card p-6 text-sm ring-1 ring-foreground/[0.04]">
595 <Card className="p-6">370 <div className="grid gap-2">
596 <div className="text-sm font-medium text-foreground">Presets</div>371 <Label>Name</Label>
597 <div className="mt-3 grid gap-3 sm:grid-cols-2">372 <Input
598 {mcpQuickPresets.map((preset) => {373 value={name}
599 const selected =374 onChange={(event) => setName(event.target.value)}
600 preset.input.transport === "stdio"375 />
601 ? transportFields.transport === "stdio"376 </div>
602 && transportFields.command === (preset.input.command ?? "")
603 : !isStdio && endpoint.trim() === (preset.input.endpoint ?? "");
604
605 return (
606 <Button
607 key={preset.id}
608 type="button"
609 variant="outline"
610 onClick={() => applyPreset(preset)}
611 className={cn(
612 "h-auto flex-col items-start px-4 py-3 text-left",
613 selected && "border-primary/30 bg-primary/5",
614 )}
615 >
616 <div className="flex items-center gap-2">
617 {selected ? <IconCheck className="size-3.5 text-primary" /> : null}
618 <div className="text-sm font-medium text-foreground">{preset.name}</div>
619 </div>
620 <div className="mt-1 text-xs text-muted-foreground">{preset.summary}</div>
621 </Button>
622 );
623 })}
624 </div>
625 </Card>
626 )}
627 377
628 {props.mode === "create" && (378 <div className="grid gap-2">
629 <Card className="p-6">379 <Label>Transport</Label>
630 <div>380 <Select
631 <div className="flex items-center justify-between gap-3">381 value={transportFields.transport}
632 <div className="text-sm font-medium text-foreground">Discover</div>382 onChange={(event) => {
633 <Button383 const nextTransport = event.target.value as McpTransportValue;
634 variant="ghost"384 setTransportFields((current) =>
635 onClick={() => setShowProbeAuth((current) => !current)}385 setMcpTransportFieldsTransport(current, nextTransport)
636 className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"386 );
637 >387 if (nextTransport === "stdio") {
638 {showProbeAuth ? "Hide auth" : "Need auth?"}388 setAuthKind("none");
639 </Button>389 setOauthAuth(null);
640 </div>390 setOauthStatus("idle");
391 }
392 }}
393 >
394 <option value="">Auto (remote)</option>
395 <option value="auto">Auto</option>
396 <option value="streamable-http">Streamable HTTP</option>
397 <option value="sse">SSE</option>
398 <option value="stdio">stdio</option>
399 </Select>
400 </div>
641 401
642 <div className="mt-2 flex flex-col gap-3 sm:flex-row">402 {isStdio ? (
403 <div className="grid gap-4 md:grid-cols-2">
404 <div className="grid gap-2 md:col-span-2">
405 <Label>Command</Label>
643 <Input406 <Input
644 value={discoveryEndpoint}407 value={transportFields.command}
645 onChange={(event) => setDiscoveryEndpoint(event.target.value)}408 onChange={(event) =>
646 placeholder="https://mcp.example.com/mcp"409 setTransportFields({
647 className="flex-1 font-mono text-xs"410 ...transportFields,
411 command: event.target.value,
412 })}
413 className="font-mono text-xs"
648 />414 />
649 <Button
650 variant="outline"
651 onClick={() => {
652 void handleDiscover();
653 }}
654 disabled={discoverMutation.status === "pending"}
655 >
656 {discoverMutation.status === "pending"
657 ? <IconSpinner className="size-3.5" />
658 : <IconSearch className="size-3.5" />}
659 {discoverMutation.status === "pending" ? "Discovering..." : "Discover"}
660 </Button>
661 </div>415 </div>
662 416
663 {showProbeAuth && (417 <div className="grid gap-2">
664 <div className="mt-3 space-y-3 rounded-lg border border-border bg-muted/20 p-4">418 <Label>Args</Label>
665 <div className="grid gap-2">419 <Textarea
666 <Label>Discovery auth</Label>420 value={transportFields.argsText}
667 <Select421 onChange={(event) =>
668 value={probeAuth.kind}422 setTransportFields({
669 onChange={(event) =>423 ...transportFields,
670 setProbeAuth((current) => ({424 argsText: event.target.value,
671 ...current,425 })}
672 kind: event.target.value as ProbeAuthState["kind"],426 rows={3}
673 }))}427 placeholder='["server.js","--port","8787"]'
674 >428 />
675 <option value="none">None</option>429 </div>
676 <option value="bearer">Bearer</option>
677 <option value="basic">Basic</option>
678 <option value="headers">Custom headers</option>
679 </Select>
680 </div>
681 430
682 {probeAuth.kind === "bearer" && (431 <div className="grid gap-2">
683 <div className="grid gap-4 md:grid-cols-2">432 <Label>Environment</Label>
684 <div className="grid gap-2">433 <Textarea
685 <Label>Header name</Label>434 value={transportFields.envText}
686 <Input435 onChange={(event) =>
687 value={probeAuth.headerName}436 setTransportFields({
688 onChange={(event) =>437 ...transportFields,
689 setProbeAuth((current) => ({ ...current, headerName: event.target.value }))}438 envText: event.target.value,
690 />439 })}
691 </div>440 rows={3}
692 <div className="grid gap-2">441 placeholder='{"NODE_ENV":"production"}'
693 <Label>Prefix</Label>442 />
694 <Input443 </div>
695 value={probeAuth.prefix}
696 onChange={(event) =>
697 setProbeAuth((current) => ({ ...current, prefix: event.target.value }))}
698 />
699 </div>
700 <div className="grid gap-2 md:col-span-2">
701 <Label>Token</Label>
702 <Input
703 value={probeAuth.token}
704 onChange={(event) =>
705 setProbeAuth((current) => ({ ...current, token: event.target.value }))}
706 className="font-mono text-xs"
707 />
708 </div>
709 </div>
710 )}
711
712 {probeAuth.kind === "basic" && (
713 <div className="grid gap-4 md:grid-cols-2">
714 <div className="grid gap-2">
715 <Label>Username</Label>
716 <Input
717 value={probeAuth.username}
718 onChange={(event) =>
719 setProbeAuth((current) => ({ ...current, username: event.target.value }))}
720 />
721 </div>
722 <div className="grid gap-2">
723 <Label>Password</Label>
724 <Input
725 value={probeAuth.password}
726 onChange={(event) =>
727 setProbeAuth((current) => ({ ...current, password: event.target.value }))}
728 type="password"
729 />
730 </div>
731 </div>
732 )}
733
734 {probeAuth.kind === "headers" && (
735 <div className="grid gap-2">
736 <Label>Headers JSON</Label>
737 <Textarea
738 value={probeAuth.headersText}
739 onChange={(event) =>
740 setProbeAuth((current) => ({ ...current, headersText: event.target.value }))}
741 rows={3}
742 placeholder='{"x-api-key":"..."}'
743 />
744 </div>
745 )}
746 </div>
747 )}
748 444
749 {discoveryMessage && (445 <div className="grid gap-2 md:col-span-2">
750 <div className="mt-2 text-xs text-muted-foreground">446 <Label>Working Directory</Label>
751 {discoveryMessage}447 <Input
752 </div>448 value={transportFields.cwd}
753 )}449 onChange={(event) =>
450 setTransportFields({
451 ...transportFields,
452 cwd: event.target.value,
453 })}
454 className="font-mono text-xs"
455 />
456 </div>
754 </div>457 </div>
755 </Card>458 ) : (
756 )}459 <div className="grid gap-4 md:grid-cols-2">
757 460 <div className="grid gap-2 md:col-span-2">
758 <Card className="space-y-6 p-6">461 <Label>Endpoint</Label>
759 <div className="grid gap-2">462 <Input
760 <Label>Name</Label>463 value={endpoint}
761 <Input464 onChange={(event) => setEndpoint(event.target.value)}
762 value={name}465 className="font-mono text-xs"
763 onChange={(event) => setName(event.target.value)}466 />
764 />467 </div>
765 </div>
766
767 <div className="grid gap-2">
768 <Label>Transport</Label>
769 <Select
770 value={transportFields.transport}
771 onChange={(event) => {
772 const nextTransport = event.target.value as McpTransportValue;
773 setTransportFields((current) =>
774 setMcpTransportFieldsTransport(current, nextTransport)
775 );
776 if (nextTransport === "stdio") {
777 setAuthKind("none");
778 setOauthAuth(null);
779 setOauthStatus("idle");
780 }
781 }}
782 >
783 <option value="">Auto (remote)</option>
784 <option value="auto">Auto</option>
785 <option value="streamable-http">Streamable HTTP</option>
786 <option value="sse">SSE</option>
787 <option value="stdio">stdio</option>
788 </Select>
789 </div>
790 468
791 {isStdio ? (469 <div className="grid gap-2">
792 <div className="grid gap-4 md:grid-cols-2">470 <Label>Query Params</Label>
793 <div className="grid gap-2 md:col-span-2">471 <Textarea
794 <Label>Command</Label>472 value={transportFields.queryParamsText}
795 <Input473 onChange={(event) =>
796 value={transportFields.command}474 setTransportFields({
797 onChange={(event) =>475 ...transportFields,
798 setTransportFields({476 queryParamsText: event.target.value,
799 ...transportFields,477 })}
800 command: event.target.value,478 rows={3}
801 })}479 placeholder='{"transport":"streamable-http"}'
802 className="font-mono text-xs"480 />
803 />481 </div>
804 </div>
805 482
806 <div className="grid gap-2">483 <div className="grid gap-2">
807 <Label>Args</Label>484 <Label>Headers</Label>
808 <Textarea485 <Textarea
809 value={transportFields.argsText}486 value={transportFields.headersText}
810 onChange={(event) =>487 onChange={(event) =>
811 setTransportFields({488 setTransportFields({
812 ...transportFields,489 ...transportFields,
813 argsText: event.target.value,490 headersText: event.target.value,
814 })}491 })}
815 rows={3}492 rows={3}
816 placeholder='["server.js","--port","8787"]'493 placeholder='{"x-api-key":"..."}'
817 />494 />
495 </div>
818 </div>496 </div>
497 )}
819 498
499 {!isStdio && (
820 <div className="grid gap-2">500 <div className="grid gap-2">
821 <Label>Environment</Label>501 <Label>Auth</Label>
822 <Textarea502 <Select
823 value={transportFields.envText}503 value={authKind}
824 onChange={(event) =>504 onChange={(event) => {
825 setTransportFields({505 const nextKind = event.target.value as McpConnectionAuth["kind"];
826 ...transportFields,506 setAuthKind(nextKind);
827 envText: event.target.value,507 if (nextKind !== "oauth2") {
828 })}508 setOauthAuth(null);
829 rows={3}509 setOauthStatus("idle");
830 placeholder='{"NODE_ENV":"production"}'510 }
831 />511 }}
832 </div>512 >
833 513 <option value="none">None</option>
834 <div className="grid gap-2 md:col-span-2">514 <option value="oauth2">OAuth 2.0</option>
835 <Label>Working Directory</Label>515 </Select>
836 <Input
837 value={transportFields.cwd}
838 onChange={(event) =>
839 setTransportFields({
840 ...transportFields,
841 cwd: event.target.value,
842 })}
843 className="font-mono text-xs"
844 />
845 </div>
846 </div>
847 ) : (
848 <div className="grid gap-4 md:grid-cols-2">
849 <div className="grid gap-2 md:col-span-2">
850 <Label>Endpoint</Label>
851 <Input
852 value={endpoint}
853 onChange={(event) => setEndpoint(event.target.value)}
854 className="font-mono text-xs"
855 />
856 </div>516 </div>
857 517 )}
858 <div className="grid gap-2">518
859 <Label>Query Params</Label>519 {!isStdio && authKind === "oauth2" && (
860 <Textarea520 <div className="rounded-lg border border-border bg-muted/20 p-4">
861 value={transportFields.queryParamsText}521 <div className="flex flex-wrap items-center justify-between gap-3">
862 onChange={(event) =>522 <div>
863 setTransportFields({523 <div className="text-sm font-medium text-foreground">OAuth</div>
864 ...transportFields,524 <div className="mt-1 text-xs text-muted-foreground">
865 queryParamsText: event.target.value,525 Authenticate with the MCP server's built-in OAuth flow.
866 })}526 </div>
867 rows={3}527 </div>
868 placeholder='{"transport":"streamable-http"}'528 <Button
869 />529 variant="outline"
530 onClick={() => {
531 setError(null);
532 setOauthStatus("pending");
533 void runOauth()
534 .catch((cause) => {
535 setOauthStatus("idle");
536 setError(
537 cause instanceof Error ? cause.message : String(cause),
538 );
539 });
540 }}
541 >
542 {oauthStatus === "connected" ? "Reconnect OAuth" : "Connect OAuth"}
543 </Button>
544 </div>
545 {oauthStatus === "connected" && (
546 <div className="mt-3 text-xs text-primary">
547 Connected
548 </div>
549 )}
870 </div>550 </div>
551 )}
871 552
872 <div className="grid gap-2">553 {error && (
873 <Label>Headers</Label>554 <Alert variant="destructive">
874 <Textarea555 {error}
875 value={transportFields.headersText}556 </Alert>
876 onChange={(event) =>557 )}
877 setTransportFields({
878 ...transportFields,
879 headersText: event.target.value,
880 })}
881 rows={3}
882 placeholder='{"x-api-key":"..."}'
883 />
884 </div>
885 </div>
886 )}
887 558
888 {!isStdio && (559 <div className="flex items-center justify-end gap-3">
889 <div className="grid gap-2">560 <Button
890 <Label>Auth</Label>561 onClick={() => {
891 <Select562 setError(null);
892 value={authKind}563 void submitMutation
893 onChange={(event) => {564 .mutateAsync(buildInput())
894 const nextKind = event.target.value as McpConnectionAuth["kind"];565 .catch((cause: unknown) =>
895 setAuthKind(nextKind);566 setError(cause instanceof Error ? cause.message : String(cause))
896 if (nextKind !== "oauth2") {567 );
897 setOauthAuth(null);
898 setOauthStatus("idle");
899 }
900 }}568 }}
569 disabled={submitMutation.status === "pending"}
901 >570 >
902 <option value="none">None</option>571 {submitMutation.status === "pending"
903 <option value="oauth2">OAuth 2.0</option>572 ? props.mode === "create"
904 </Select>573 ? "Creating..."
905 </div>574 : "Saving..."
906 )}575 : props.mode === "create"
907 576 ? "Create Source"
908 {!isStdio && authKind === "oauth2" && (577 : "Save Changes"}
909 <div className="rounded-lg border border-border bg-muted/20 p-4">578 </Button>
910 <div className="flex flex-wrap items-center justify-between gap-3">
911 <div>
912 <div className="text-sm font-medium text-foreground">OAuth</div>
913 <div className="mt-1 text-xs text-muted-foreground">
914 Authenticate with the MCP server's built-in OAuth flow.
915 </div>
916 </div>
917 <Button
918 variant="outline"
919 onClick={() => {
920 setError(null);
921 setOauthStatus("pending");
922 void runOauth()
923 .catch((cause) => {
924 setOauthStatus("idle");
925 setError(
926 cause instanceof Error ? cause.message : String(cause),
927 );
928 });
929 }}
930 >
931 {oauthStatus === "connected" ? "Reconnect OAuth" : "Connect OAuth"}
932 </Button>
933 </div>
934 {oauthStatus === "connected" && (
935 <div className="mt-3 text-xs text-primary">
936 Connected
937 </div>
938 )}
939 </div>579 </div>
940 )}
941
942 {error && (
943 <Alert variant="destructive">
944 {error}
945 </Alert>
946 )}
947
948 <div className="flex items-center justify-end gap-3">
949 <Button
950 onClick={() => {
951 setError(null);
952 void submitMutation
953 .mutateAsync(buildInput())
954 .catch((cause: unknown) =>
955 setError(cause instanceof Error ? cause.message : String(cause))
956 );
957 }}
958 disabled={submitMutation.status === "pending"}
959 >
960 {submitMutation.status === "pending"
961 ? props.mode === "create"
962 ? "Creating..."
963 : "Saving..."
964 : props.mode === "create"
965 ? "Create Source"
966 : "Save Changes"}
967 </Button>
968 </div>580 </div>
969 </Card>
970 </div>581 </div>
971 );582 );
972}583}
@@ -986,27 +597,40 @@ export function McpAddPage() { · 13 unmodified lines
986 }597 }
987 598
988 return (599 return (
989 <McpSourceForm600 <div className="min-h-0 flex-1 overflow-y-auto">
990 initialValue={initialValue}601 <div className="mx-auto w-full max-w-3xl px-6 py-10">
991 mode="create"602 <div className="mb-8">
992 onSubmit={async (input) => {603 <h1 className="font-display text-2xl tracking-tight text-foreground">
993 const source = await createSource({604 Add MCP Source
994 path: {605 </h1>
995 workspaceId: installation.data.scopeId,606 <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
996 },607 Connect a Model Context Protocol server and import its tools.
997 payload: input,608 </p>
998 reactivityKeys: {609 </div>
999 sources: [installation.data.scopeId],610
1000 },611 <McpSourceForm
1001 });612 initialValue={initialValue}
1002 613 mode="create"
1003 startTransition(() => {614 onSubmit={async (input) => {
1004 void navigation.detail(source.id, {615 const source = await createSource({
1005 tab: "model",616 path: {
1006 });617 workspaceId: installation.data.scopeId,
1007 });618 },
1008 }}619 payload: input,
1009 />620 reactivityKeys: {
621 sources: [installation.data.scopeId],
622 },
623 });
624
625 startTransition(() => {
626 void navigation.detail(source.id, {
627 tab: "model",
628 });
629 });
630 }}
631 />
632 </div>
633 </div>
1010 );634 );
1011}635}
1012 636
@@ -1054,32 +678,45 @@ export function McpEditPage(props: { · 41 unmodified lines
1054 }678 }
1055 679
1056 return (680 return (
1057 <McpSourceForm681 <div className="min-h-0 flex-1 overflow-y-auto">
1058 initialValue={configResult.value}682 <div className="mx-auto w-full max-w-3xl px-6 py-10">
1059 mode="edit"683 <div className="mb-8">
1060 onSubmit={async (input) => {684 <h1 className="font-display text-2xl tracking-tight text-foreground">
1061 const source = await updateSource({685 Edit MCP Source
1062 path: {686 </h1>
1063 workspaceId: installation.data.scopeId,687 <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
1064 sourceId: props.source.id,688 Update the connection settings for {props.source.name}.
1065 },689 </p>
1066 payload: input,690 </div>
1067 reactivityKeys: {691
1068 sources: [installation.data.scopeId],692 <McpSourceForm
1069 source: [installation.data.scopeId, props.source.id],693 initialValue={configResult.value}
1070 sourceInspection: [installation.data.scopeId, props.source.id],694 mode="edit"
1071 sourceInspectionTool: [installation.data.scopeId, props.source.id],695 onSubmit={async (input) => {
1072 sourceDiscovery: [installation.data.scopeId, props.source.id],696 const source = await updateSource({
1073 },697 path: {
1074 });698 workspaceId: installation.data.scopeId,
1075 699 sourceId: props.source.id,
1076 startTransition(() => {700 },
1077 void navigation.detail(source.id, {701 payload: input,
1078 tab: "model",702 reactivityKeys: {
1079 });703 sources: [installation.data.scopeId],
1080 });704 source: [installation.data.scopeId, props.source.id],
1081 }}705 sourceInspection: [installation.data.scopeId, props.source.id],
1082 />706 sourceInspectionTool: [installation.data.scopeId, props.source.id],
707 sourceDiscovery: [installation.data.scopeId, props.source.id],
708 },
709 });
710
711 startTransition(() => {
712 void navigation.detail(source.id, {
713 tab: "model",
714 });
715 });
716 }}
717 />
718 </div>
719 </div>
1083 );720 );
1084}721}
1085 722
plugins/mcp/react/index.ts+7−4
@@ -1,4 +1,7 @@
1import { defineExecutorFrontendPlugin } from "@executor/react/plugins";1import {
2 defineExecutorFrontendPlugin,
3 liveFrontendPluginComponent,
4} from "@executor/react/plugins";
2 5
3import {6import {
4 McpAddPage,7 McpAddPage,
@@ -14,17 +17,17 @@ export const McpReactPlugin = defineExecutorFrontendPlugin({ · 9 unmodified lines
14 {17 {
15 key: "add",18 key: "add",
16 path: "add",19 path: "add",
17 component: McpAddPage,20 component: liveFrontendPluginComponent(() => McpAddPage),
18 },21 },
19 {22 {
20 key: "detail",23 key: "detail",
21 path: "sources/$sourceId",24 path: "sources/$sourceId",
22 component: McpDetailRoute,25 component: liveFrontendPluginComponent(() => McpDetailRoute),
23 },26 },
24 {27 {
25 key: "edit",28 key: "edit",
26 path: "sources/$sourceId/edit",29 path: "sources/$sourceId/edit",
27 component: McpEditRoute,30 component: liveFrontendPluginComponent(() => McpEditRoute),
28 },31 },
29 ],32 ],
30});33});
plugins/onepassword/react/index.ts+7−2
@@ -1,4 +1,7 @@
1import { defineExecutorFrontendPlugin } from "@executor/react/plugins";1import {
2 defineExecutorFrontendPlugin,
3 liveFrontendPluginComponent,
4} from "@executor/react/plugins";
2import { ONEPASSWORD_SECRET_STORE_KIND } from "@executor/plugin-onepassword-shared";5import { ONEPASSWORD_SECRET_STORE_KIND } from "@executor/plugin-onepassword-shared";
3 6
4import { OnePasswordSecretStoreCreateForm } from "./components";7import { OnePasswordSecretStoreCreateForm } from "./components";
@@ -8,6 +11,8 @@ export const OnePasswordReactPlugin = defineExecutorFrontendPlugin({ · 3 unmodified lines
8 displayName: "1Password",11 displayName: "1Password",
9 secretStore: {12 secretStore: {
10 kind: ONEPASSWORD_SECRET_STORE_KIND,13 kind: ONEPASSWORD_SECRET_STORE_KIND,
11 CreateStoreForm: OnePasswordSecretStoreCreateForm,14 CreateStoreForm: liveFrontendPluginComponent(
15 () => OnePasswordSecretStoreCreateForm,
16 ),
12 },17 },
13});18});
plugins/openapi/react/components.tsx+140−125
@@ -15,7 +15,6 @@ import { · 14 unmodified lines
15import {15import {
16 Alert,16 Alert,
17 Button,17 Button,
18 Card,
19 DocumentPanel,18 DocumentPanel,
20 IconPencil,19 IconPencil,
21 Input,20 Input,
@@ -149,7 +148,7 @@ const Section = (props: { · 127 unmodified lines
149 children: ReactNode;148 children: ReactNode;
150}) => (149}) => (
151 <section>150 <section>
152 <h2 className="mb-3 text-sm font-semibold text-foreground">{props.title}</h2>151 <h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">{props.title}</h2>
153 {props.children}152 {props.children}
154 </section>153 </section>
155);154);
@@ -387,7 +386,7 @@ function OpenApiSourceForm(props: { · 231 unmodified lines
387 };386 };
388 387
389 return (388 return (
390 <Card className="space-y-6 p-6">389 <div className="space-y-6 rounded-lg border border-border bg-card p-6 text-sm ring-1 ring-foreground/[0.04]">
391 <Section title="Connection">390 <Section title="Connection">
392 <div className="grid gap-4">391 <div className="grid gap-4">
393 <div className="grid gap-2">392 <div className="grid gap-2">
@@ -477,59 +476,47 @@ function OpenApiSourceForm(props: { · 83 unmodified lines
477 </div>476 </div>
478 </Section>477 </Section>
479 478
480 <Section title="Preview">479 {(preview || previewMutation.status === "pending") && (
481 <div className="flex items-center gap-3">480 <Section title="Preview">
482 <Button481 {previewMutation.status === "pending" ? (
483 variant="outline"482 <div className="text-xs text-muted-foreground">Loading preview...</div>
484 type="button"483 ) : preview && (
485 onClick={() => {484 <div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm">
486 void runPreview({ mode: "manual" });485 <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
487 }}486 <dt className="font-medium text-foreground">Title</dt>
488 disabled={previewMutation.status === "pending" || submitMutation.status === "pending"}487 <dd className="text-muted-foreground">{preview.title ?? "—"}</dd>
489 >488 <dt className="font-medium text-foreground">Version</dt>
490 {previewMutation.status === "pending" ? "Previewing..." : "Preview Spec"}489 <dd className="text-muted-foreground">{preview.version ?? "—"}</dd>
491 </Button>490 <dt className="font-medium text-foreground">Operations</dt>
492 {preview && (491 <dd className="text-muted-foreground">{preview.operationCount}</dd>
493 <div className="text-xs text-muted-foreground">492 <dt className="font-medium text-foreground">Base URL</dt>
494 {preview.operationCount} operations493 <dd className="font-mono text-muted-foreground">{preview.baseUrl ?? "—"}</dd>
495 {preview.version ? ` · v${preview.version}` : ""}494 {preview.namespace && (
495 <>
496 <dt className="font-medium text-foreground">Namespace</dt>
497 <dd className="text-muted-foreground">{preview.namespace}</dd>
498 </>
499 )}
500 {preview.securitySchemes.length > 0 && (
501 <>
502 <dt className="font-medium text-foreground">Auth</dt>
503 <dd className="text-muted-foreground">
504 {preview.securitySchemes
505 .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`)
506 .join(", ")}
507 </dd>
508 </>
509 )}
510 </dl>
511 {preview.warnings.length > 0 && (
512 <Alert variant="warning" className="mt-3 text-xs">
513 {preview.warnings.join(" ")}
514 </Alert>
515 )}
496 </div>516 </div>
497 )}517 )}
498 </div>518 </Section>
499 {preview && (519 )}
500 <div className="mt-3 rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm">
501 <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
502 <dt className="font-medium text-foreground">Title</dt>
503 <dd className="text-muted-foreground">{preview.title ?? "—"}</dd>
504 <dt className="font-medium text-foreground">Version</dt>
505 <dd className="text-muted-foreground">{preview.version ?? "—"}</dd>
506 <dt className="font-medium text-foreground">Base URL</dt>
507 <dd className="font-mono text-muted-foreground">{preview.baseUrl ?? "—"}</dd>
508 {preview.namespace && (
509 <>
510 <dt className="font-medium text-foreground">Namespace</dt>
511 <dd className="text-muted-foreground">{preview.namespace}</dd>
512 </>
513 )}
514 {preview.securitySchemes.length > 0 && (
515 <>
516 <dt className="font-medium text-foreground">Auth</dt>
517 <dd className="text-muted-foreground">
518 {preview.securitySchemes
519 .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`)
520 .join(", ")}
521 </dd>
522 </>
523 )}
524 </dl>
525 {preview.warnings.length > 0 && (
526 <Alert variant="warning" className="mt-3 text-xs">
527 {preview.warnings.join(" ")}
528 </Alert>
529 )}
530 </div>
531 )}
532 </Section>
533 520
534 {error && (521 {error && (
535 <Alert variant="destructive">522 <Alert variant="destructive">
@@ -548,7 +535,7 @@ function OpenApiSourceForm(props: { · 12 unmodified lines
548 {submitMutation.status === "pending" ? props.busyLabel : props.submitLabel}535 {submitMutation.status === "pending" ? props.busyLabel : props.submitLabel}
549 </Button>536 </Button>
550 </div>537 </div>
551 </Card>538 </div>
552 );539 );
553}540}
554 541
@@ -564,35 +551,50 @@ export function OpenApiAddSourcePage() { · 9 unmodified lines
564 551
565 if (!workspace.enabled) {552 if (!workspace.enabled) {
566 return (553 return (
567 <Section title="Add Source">554 <div className="min-h-0 flex-1 overflow-y-auto">
568 <div className="text-sm text-muted-foreground">Loading workspace...</div>555 <div className="mx-auto w-full max-w-3xl px-6 py-10">
569 </Section>556 <div className="text-sm text-muted-foreground">Loading workspace...</div>
557 </div>
558 </div>
570 );559 );
571 }560 }
572 561
573 return (562 return (
574 <OpenApiSourceForm563 <div className="min-h-0 flex-1 overflow-y-auto">
575 mode="create"564 <div className="mx-auto w-full max-w-3xl px-6 py-10">
576 workspaceId={workspace.workspaceId}565 <div className="mb-8">
577 initialValue={initialValue}566 <h1 className="font-display text-2xl tracking-tight text-foreground">
578 submitLabel="Create Source"567 Add OpenAPI Source
579 busyLabel="Creating..."568 </h1>
580 onSubmit={async (payload) => {569 <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
581 const source = await createSource({570 Import operations from an OpenAPI specification as callable tools.
582 path: { workspaceId: workspace.workspaceId },571 </p>
583 payload,572 </div>
584 reactivityKeys: {573
585 sources: [workspace.workspaceId],574 <OpenApiSourceForm
586 },575 mode="create"
587 });576 workspaceId={workspace.workspaceId}
588 577 initialValue={initialValue}
589 startTransition(() => {578 submitLabel="Create Source"
590 void navigation.detail(source.id, {579 busyLabel="Creating..."
591 tab: "model",580 onSubmit={async (payload) => {
592 });581 const source = await createSource({
593 });582 path: { workspaceId: workspace.workspaceId },
594 }}583 payload,
595 />584 reactivityKeys: {
585 sources: [workspace.workspaceId],
586 },
587 });
588
589 startTransition(() => {
590 void navigation.detail(source.id, {
591 tab: "model",
592 });
593 });
594 }}
595 />
596 </div>
597 </div>
596 );598 );
597}599}
598 600
@@ -603,9 +605,11 @@ export function OpenApiEditSourcePage(props: { · 4 unmodified lines
603 605
604 if (!workspace.enabled) {606 if (!workspace.enabled) {
605 return (607 return (
606 <Section title="Edit Source">608 <div className="min-h-0 flex-1 overflow-y-auto">
607 <div className="text-sm text-muted-foreground">Loading workspace...</div>609 <div className="mx-auto w-full max-w-3xl px-6 py-10">
608 </Section>610 <div className="text-sm text-muted-foreground">Loading workspace...</div>
611 </div>
612 </div>
609 );613 );
610 }614 }
611 615
@@ -641,53 +645,64 @@ function OpenApiEditSourcePageReady(props: { · 29 unmodified lines
641 );645 );
642 646
643 if (!Result.isSuccess(configResult)) {647 if (!Result.isSuccess(configResult)) {
644 if (Result.isFailure(configResult)) {
645 return (
646 <Section title="Edit Source">
647 <Alert variant="destructive">
648 Failed loading source configuration.
649 </Alert>
650 </Section>
651 );
652 }
653
654 return (648 return (
655 <Section title="Edit Source">649 <div className="min-h-0 flex-1 overflow-y-auto">
656 <div className="text-sm text-muted-foreground">Loading configuration...</div>650 <div className="mx-auto w-full max-w-3xl px-6 py-10">
657 </Section>651 {Result.isFailure(configResult) ? (
652 <Alert variant="destructive">
653 Failed loading source configuration.
654 </Alert>
655 ) : (
656 <div className="text-sm text-muted-foreground">Loading configuration...</div>
657 )}
658 </div>
659 </div>
658 );660 );
659 }661 }
660 662
661 return (663 return (
662 <OpenApiSourceForm664 <div className="min-h-0 flex-1 overflow-y-auto">
663 mode="edit"665 <div className="mx-auto w-full max-w-3xl px-6 py-10">
664 workspaceId={props.workspaceId}666 <div className="mb-8">
665 initialValue={inputFromConfig(configResult.value)}667 <h1 className="font-display text-2xl tracking-tight text-foreground">
666 submitLabel="Save Changes"668 Edit OpenAPI Source
667 busyLabel="Saving..."669 </h1>
668 onSubmit={async (config) => {670 <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
669 const source = await updateSource({671 Update the connection settings for {props.source.name}.
670 path: {672 </p>
671 workspaceId: props.workspaceId,673 </div>
672 sourceId: props.source.id,674
673 },675 <OpenApiSourceForm
674 payload: config,676 mode="edit"
675 reactivityKeys: {677 workspaceId={props.workspaceId}
676 sources: [props.workspaceId],678 initialValue={inputFromConfig(configResult.value)}
677 source: [props.workspaceId, props.source.id],679 submitLabel="Save Changes"
678 sourceInspection: [props.workspaceId, props.source.id],680 busyLabel="Saving..."
679 sourceInspectionTool: [props.workspaceId, props.source.id],681 onSubmit={async (config) => {
680 sourceDiscovery: [props.workspaceId, props.source.id],682 const source = await updateSource({
681 },683 path: {
682 });684 workspaceId: props.workspaceId,
683 685 sourceId: props.source.id,
684 startTransition(() => {686 },
685 void navigation.detail(source.id, {687 payload: config,
686 tab: "model",688 reactivityKeys: {
687 });689 sources: [props.workspaceId],
688 });690 source: [props.workspaceId, props.source.id],
689 }}691 sourceInspection: [props.workspaceId, props.source.id],
690 />692 sourceInspectionTool: [props.workspaceId, props.source.id],
693 sourceDiscovery: [props.workspaceId, props.source.id],
694 },
695 });
696
697 startTransition(() => {
698 void navigation.detail(source.id, {
699 tab: "model",
700 });
701 });
702 }}
703 />
704 </div>
705 </div>
691 );706 );
692}707}
693 708
plugins/openapi/react/index.ts+7−4
@@ -1,4 +1,7 @@
1import { defineExecutorFrontendPlugin } from "@executor/react/plugins";1import {
2 defineExecutorFrontendPlugin,
3 liveFrontendPluginComponent,
4} from "@executor/react/plugins";
2 5
3import {6import {
4 OpenApiAddSourcePage,7 OpenApiAddSourcePage,
@@ -13,17 +16,17 @@ export const OpenApiReactPlugin = defineExecutorFrontendPlugin({ · 8 unmodified lines
13 {16 {
14 key: "add",17 key: "add",
15 path: "add",18 path: "add",
16 component: OpenApiAddSourcePage,19 component: liveFrontendPluginComponent(() => OpenApiAddSourcePage),
17 },20 },
18 {21 {
19 key: "detail",22 key: "detail",
20 path: "sources/$sourceId",23 path: "sources/$sourceId",
21 component: OpenApiDetailRoute,24 component: liveFrontendPluginComponent(() => OpenApiDetailRoute),
22 },25 },
23 {26 {
24 key: "edit",27 key: "edit",
25 path: "sources/$sourceId/edit",28 path: "sources/$sourceId/edit",
26 component: OpenApiEditRoute,29 component: liveFrontendPluginComponent(() => OpenApiEditRoute),
27 },30 },
28 ],31 ],
29});32});