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 @@
11 import { useEffect, useState } from "react";
22 import { Button } from "@executor/react/plugins";
33 import { CodeBlock } from "./code-block";
4−import { cn } from "../lib/utils";
54
65 type LocalMcpInstallMode = "stdio" | "http";
76
apps/web/vite.config.ts+15−0
@@ -34,6 +34,20 @@ const workspaceExecutorPackages = Object.keys(webPackage.dependencies ?? {}).fil · 33 unmodified lines
3434 (name) => name.startsWith("@executor/"),
3535 );
3636
37+const pluginWorkspaceSegment = `${path.sep}plugins${path.sep}`;
38+
39+const 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+
3751 export default defineConfig({
3852 root: "src",
3953 resolve: {
@@ -42,6 +56,7 @@ export default defineConfig({ · 2 unmodified lines
4256 },
4357 },
4458 plugins: [
59+ reloadOnPluginChange(),
4560 tailwindcss(),
4661 react(),
4762 devServer({
packages/clients/react/src/components/secret-reference-field.tsx+3−3
@@ -80,9 +80,9 @@ export function SecretReferenceField(props: SecretReferenceFieldProps) { · 79 unmodified lines
8080 path: {
8181 storeId: input.storeId,
8282 },
83− payload: {
84− ...(input.query?.trim() ? { query: input.query.trim() } : {}),
85− },
83+ payload: input.query?.trim()
84+ ? { query: input.query.trim() }
85+ : {},
8686 }),
8787 [browseSecretStoreMutation],
8888 );
packages/clients/react/src/plugins/index.ts+1−0
@@ -35,6 +35,7 @@ export { · 34 unmodified lines
3535 export {
3636 defineExecutorFrontendPlugin,
3737 defineFrontendPluginRoute,
38+ liveFrontendPluginComponent,
3839 registerExecutorFrontendPlugins,
3940 type RegisteredFrontendPluginRoute,
4041 } from "./registry";
packages/clients/react/src/plugins/registry.ts+22−0
@@ -1,3 +1,7 @@
1+import {
2+ createElement,
3+ type ComponentType,
4+} from "react";
15 import type {
26 ExecutorFrontendPlugin,
37 FrontendPluginRouteDefinition,
@@ -23,6 +27,24 @@ export const defineExecutorFrontendPlugin = < · 19 unmodified lines
2327 input: TPlugin,
2428 ): TPlugin => input;
2529
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.
32+export 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+
2648 export const registerExecutorFrontendPlugins = (
2749 plugins: readonly ExecutorFrontendPlugin[],
2850 ) => {
plugins/execution-history/react/index.ts+3−2
@@ -1,5 +1,6 @@
11 import {
22 defineExecutorFrontendPlugin,
3+ liveFrontendPluginComponent,
34 } from "@executor/react/plugins";
45
56 import {
@@ -14,7 +15,7 @@ export const ExecutionHistoryReactPlugin = defineExecutorFrontendPlugin({ · 8 unmodified lines
1415 routes: [
1516 {
1617 key: "history",
17− component: ExecutionHistoryPage,
18+ component: liveFrontendPluginComponent(() => ExecutionHistoryPage),
1819 nav: {
1920 label: "Runs",
2021 section: "main",
@@ -23,7 +24,7 @@ export const ExecutionHistoryReactPlugin = defineExecutorFrontendPlugin({ · 2 unmodified lines
2324 {
2425 key: "detail",
2526 path: "$executionId",
26− component: ExecutionHistoryDetailPage,
27+ component: liveFrontendPluginComponent(() => ExecutionHistoryDetailPage),
2728 },
2829 ],
2930 });
plugins/google-discovery/react/components.tsx+95−70
@@ -14,7 +14,6 @@ import { · 13 unmodified lines
1414 Alert,
1515 Badge,
1616 Button,
17− Card,
1817 IconPencil,
1918 Input,
2019 Label,
@@ -573,7 +572,7 @@ function GoogleDiscoverySourceForm(props: { · 552 unmodified lines
573572 };
574573
575574 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]">
577576 <div className="grid gap-4 sm:grid-cols-2">
578577 <div className="grid gap-2 sm:col-span-2">
579578 <Label>Name</Label>
@@ -753,7 +752,7 @@ function GoogleDiscoverySourceForm(props: { · 173 unmodified lines
753752 : props.mode === "create" ? "Create Source" : "Save Changes"}
754753 </Button>
755754 </div>
756− </Card>
755+ </div>
757756 );
758757 }
759758
@@ -857,7 +856,7 @@ function GoogleDiscoveryBatchConnectPanel(props: { · 97 unmodified lines
857856 };
858857
859858 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]">
861860 <div>
862861 <h2 className="text-base font-semibold text-foreground">
863862 Connect multiple Google APIs
@@ -1008,7 +1007,7 @@ function GoogleDiscoveryBatchConnectPanel(props: { · 144 unmodified lines
10081007 : "Connect Google APIs"}
10091008 </Button>
10101009 </div>
1011− </Card>
1010+ </div>
10121011 );
10131012 }
10141013
@@ -1027,51 +1026,64 @@ export function GoogleDiscoveryAddPage() { · 12 unmodified lines
10271026 }
10281027
10291028 return (
1030− <div className="space-y-6">
1031− <GoogleDiscoveryBatchConnectPanel
1032− onConnected={(sources) => {
1033− startTransition(() => {
1034− if (sources.length === 1) {
1035− void navigation.detail(sources[0]!.id, {
1036− tab: "model",
1029+ <div className="min-h-0 flex-1 overflow-y-auto">
1030+ <div className="mx-auto w-full max-w-3xl px-6 py-10">
1031+ <div className="mb-8">
1032+ <h1 className="font-display text-2xl tracking-tight text-foreground">
1033+ Add Google API Source
1034+ </h1>
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();
10371052 });
1038− return;
1039− }
1053+ }}
1054+ />
10401055
1041− void navigation.home();
1042− });
1043− }}
1044− />
1056+ <div className="flex items-center gap-3">
1057+ <Separator className="flex-1" />
1058+ <div className="text-xs font-medium text-muted-foreground">
1059+ or add a single API
1060+ </div>
1061+ <Separator className="flex-1" />
1062+ </div>
10451063
1046− <div className="flex items-center gap-3">
1047− <Separator className="flex-1" />
1048− <div className="text-xs font-medium text-muted-foreground">
1049− or add a single API
1064+ <GoogleDiscoverySourceForm
1065+ initialValue={initialValue}
1066+ mode="create"
1067+ 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+ />
10501085 </div>
1051− <Separator className="flex-1" />
10521086 </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− />
10751087 </div>
10761088 );
10771089 }
@@ -1120,32 +1132,45 @@ export function GoogleDiscoveryEditPage(props: { · 42 unmodified lines
11201132 }
11211133
11221134 return (
1123− <GoogleDiscoverySourceForm
1124− initialValue={configResult.value}
1125− mode="edit"
1126− onSubmit={async (input) => {
1127− const source = await updateSource({
1128− path: {
1129− workspaceId: installation.data.scopeId,
1130− sourceId: props.source.id,
1131− },
1132− payload: input,
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− });
1135+ <div className="min-h-0 flex-1 overflow-y-auto">
1136+ <div className="mx-auto w-full max-w-3xl px-6 py-10">
1137+ <div className="mb-8">
1138+ <h1 className="font-display text-2xl tracking-tight text-foreground">
1139+ Edit Google API Source
1140+ </h1>
1141+ <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
1142+ Update the connection settings for {props.source.name}.
1143+ </p>
1144+ </div>
11411145
1142− startTransition(() => {
1143− void navigation.detail(source.id, {
1144− tab: "model",
1145− });
1146− });
1147− }}
1148− />
1146+ <GoogleDiscoverySourceForm
1147+ initialValue={configResult.value}
1148+ mode="edit"
1149+ onSubmit={async (input) => {
1150+ const source = await updateSource({
1151+ path: {
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>
11491174 );
11501175 }
11511176
plugins/google-discovery/react/index.ts+7−4
@@ -1,4 +1,7 @@
1−import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
1+import {
2+ defineExecutorFrontendPlugin,
3+ liveFrontendPluginComponent,
4+} from "@executor/react/plugins";
25
36 import {
47 GOOGLE_DISCOVERY_PLUGIN_KEY,
@@ -22,17 +25,17 @@ export const GoogleDiscoveryReactPlugin = defineExecutorFrontendPlugin({ · 17 unmodified lines
2225 {
2326 key: "add",
2427 path: "add",
25− component: GoogleDiscoveryAddPage,
28+ component: liveFrontendPluginComponent(() => GoogleDiscoveryAddPage),
2629 },
2730 {
2831 key: "detail",
2932 path: "sources/$sourceId",
30− component: GoogleDiscoveryDetailRoute,
33+ component: liveFrontendPluginComponent(() => GoogleDiscoveryDetailRoute),
3134 },
3235 {
3336 key: "edit",
3437 path: "sources/$sourceId/edit",
35− component: GoogleDiscoveryEditRoute,
38+ component: liveFrontendPluginComponent(() => GoogleDiscoveryEditRoute),
3639 },
3740 ],
3841 });
plugins/graphql/react/components.tsx+170−50
@@ -214,7 +214,7 @@ function GraphqlSourceForm(props: { · 213 unmodified lines
214214 const [error, setError] = useState<string | null>(null);
215215
216216 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]">
218218 <div className="grid gap-4">
219219 <div className="grid gap-2">
220220 <Label>Name</Label>
@@ -327,7 +327,7 @@ function GraphqlSourceForm(props: { · 106 unmodified lines
327327 : props.mode === "create" ? "Create Source" : "Save Changes"}
328328 </Button>
329329 </div>
330− </Card>
330+ </div>
331331 );
332332 }
333333
@@ -341,32 +341,46 @@ export function GraphqlAddPage() { · 7 unmodified lines
341341 { mode: "promise" },
342342 );
343343
344+
344345 if (installation.status !== "ready") {
345346 return <div className="text-sm text-muted-foreground">Loading workspace...</div>;
346347 }
347348
348349 return (
349− <GraphqlSourceForm
350− initialValue={initialValue}
351− mode="create"
352− onSubmit={async (input) => {
353− const source = await createSource({
354− path: {
355− workspaceId: installation.data.scopeId,
356− },
357− payload: input,
358− reactivityKeys: {
359− sources: [installation.data.scopeId],
360− },
361− });
362−
363− startTransition(() => {
364− void navigation.detail(source.id, {
365− tab: "model",
366− });
367− });
368− }}
369− />
350+ <div className="min-h-0 flex-1 overflow-y-auto">
351+ <div className="mx-auto w-full max-w-3xl px-6 py-10">
352+ <div className="mb-8">
353+ <h1 className="font-display text-2xl tracking-tight text-foreground">
354+ Add GraphQL Source
355+ </h1>
356+ <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
357+ Connect a GraphQL endpoint and expose its operations as tools.
358+ </p>
359+ </div>
360+
361+ <GraphqlSourceForm
362+ initialValue={initialValue}
363+ mode="create"
364+ onSubmit={async (input) => {
365+ const source = await createSource({
366+ path: {
367+ workspaceId: installation.data.scopeId,
368+ },
369+ payload: input,
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>
370384 );
371385 }
372386
@@ -414,32 +428,45 @@ export function GraphqlEditPage(props: { · 41 unmodified lines
414428 }
415429
416430 return (
417− <GraphqlSourceForm
418− initialValue={configResult.value}
419− mode="edit"
420− onSubmit={async (input) => {
421− const source = await updateSource({
422− path: {
423− workspaceId: installation.data.scopeId,
424− sourceId: props.source.id,
425− },
426− payload: input,
427− reactivityKeys: {
428− sources: [installation.data.scopeId],
429− source: [installation.data.scopeId, props.source.id],
430− sourceInspection: [installation.data.scopeId, props.source.id],
431− sourceInspectionTool: [installation.data.scopeId, props.source.id],
432− sourceDiscovery: [installation.data.scopeId, props.source.id],
433− },
434− });
435−
436− startTransition(() => {
437− void navigation.detail(source.id, {
438− tab: "model",
439− });
440− });
441− }}
442− />
431+ <div className="min-h-0 flex-1 overflow-y-auto">
432+ <div className="mx-auto w-full max-w-3xl px-6 py-10">
433+ <div className="mb-8">
434+ <h1 className="font-display text-2xl tracking-tight text-foreground">
435+ Edit GraphQL Source
436+ </h1>
437+ <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
438+ Update the connection settings for {props.source.name}.
439+ </p>
440+ </div>
441+
442+ <GraphqlSourceForm
443+ initialValue={configResult.value}
444+ mode="edit"
445+ onSubmit={async (input) => {
446+ const source = await updateSource({
447+ path: {
448+ workspaceId: installation.data.scopeId,
449+ sourceId: props.source.id,
450+ },
451+ payload: input,
452+ reactivityKeys: {
453+ sources: [installation.data.scopeId],
454+ source: [installation.data.scopeId, props.source.id],
455+ sourceInspection: [installation.data.scopeId, props.source.id],
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>
443470 );
444471 }
445472
@@ -542,6 +569,91 @@ function GraphqlDetailExplorer(props: { · 96 unmodified lines
542569 };
543570
544571 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+ }
545657
546658 return (
547659 <SourceToolExplorer
@@ -661,7 +773,7 @@ function GraphqlSourceRoute(props: { · 113 unmodified lines
661773 const sourceId = typeof params.sourceId === "string" ? params.sourceId : null;
662774 const source = useSource(sourceId ?? "");
663775
664− if (sourceId === null || source.status === "error") {
776+ if (sourceId === null) {
665777 return (
666778 <div className="px-6 py-8 text-sm text-destructive">
667779 This GraphQL source is unavailable.
@@ -677,6 +789,14 @@ function GraphqlSourceRoute(props: { · 9 unmodified lines
677789 );
678790 }
679791
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+
680800 if (source.data.kind !== "graphql") {
681801 return (
682802 <div className="px-6 py-8 text-sm text-destructive">
plugins/graphql/react/index.ts+8−5
@@ -1,4 +1,7 @@
1−import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
1+import {
2+ defineExecutorFrontendPlugin,
3+ liveFrontendPluginComponent,
4+} from "@executor/react/plugins";
25
36 import {
47 GraphqlAddPage,
@@ -15,22 +18,22 @@ export const GraphqlReactPlugin = defineExecutorFrontendPlugin({ · 10 unmodified lines
1518 {
1619 key: "add",
1720 path: "add",
18− component: GraphqlAddPage,
21+ component: liveFrontendPluginComponent(() => GraphqlAddPage),
1922 },
2023 {
2124 key: "detail",
2225 path: "sources/$sourceId",
23− component: GraphqlDetailRoute,
26+ component: liveFrontendPluginComponent(() => GraphqlDetailRoute),
2427 },
2528 {
2629 key: "edit",
2730 path: "sources/$sourceId/edit",
28− component: GraphqlEditRoute,
31+ component: liveFrontendPluginComponent(() => GraphqlEditRoute),
2932 },
3033 {
3134 key: "tool-detail",
3235 path: "sources/$sourceId/tool/$toolPath",
33− component: GraphqlToolDetailRoute,
36+ component: liveFrontendPluginComponent(() => GraphqlToolDetailRoute),
3437 },
3538 ],
3639 });
plugins/local-tools/react/components.tsx+45−44
@@ -9,7 +9,6 @@ import { · 8 unmodified lines
99 import {
1010 Alert,
1111 Button,
12− Card,
1312 SourceToolExplorer,
1413 parseSourceToolExplorerSearch,
1514 useSourcePluginNavigation,
@@ -118,52 +117,54 @@ export function LocalToolsAddPage() { · 102 unmodified lines
118117 ) ?? null;
119118
120119 return (
121− <div className="mx-auto w-full max-w-3xl px-6 py-10">
122− <Card className="p-6">
123− <h1 className="font-display text-2xl tracking-tight text-foreground">
124− Local Tools
125− </h1>
126− <p className="mt-2 text-sm leading-6 text-muted-foreground">
127− Promote file-backed tools in <code className="rounded bg-muted px-1 py-0.5 font-mono">.executor/tools</code>
128− {" "}into a first-class source with inspection and discovery.
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>.
120+ <div className="min-h-0 flex-1 overflow-y-auto">
121+ <div className="mx-auto w-full max-w-3xl px-6 py-10">
122+ <div className="mb-8">
123+ <h1 className="font-display text-2xl tracking-tight text-foreground">
124+ Local Tools
125+ </h1>
126+ <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
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.
135128 </p>
136− <p className="mt-3">
137− Existing tool path behavior is preserved, so a file like
138− {" "}<code className="rounded bg-muted px-1 py-0.5 font-mono">.executor/tools/demo.ts</code>
139− {" "}still appears as <code className="rounded bg-muted px-1 py-0.5 font-mono">tools.demo(...)</code>.
140− </p>
141− </Card>
142−
143− <div className="mt-6 flex flex-wrap items-center gap-3">
144− {localToolsSource ? (
145− <Button
146− size="lg"
147− type="button"
148− onClick={() => {
149− startTransition(() => {
150− void navigation.detail(localToolsSource.id, {
151− tab: "model",
129+ </div>
130+
131+ <div className="space-y-6 rounded-lg border border-border bg-card p-6 text-sm ring-1 ring-foreground/[0.04]">
132+ <div className="rounded-lg border border-border bg-muted/20 p-4 text-sm leading-6 text-muted-foreground">
133+ <p>
134+ The source is provisioned automatically when the executor session starts and finds
135+ local tool files in <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">.executor/tools</code>.
136+ </p>
137+ <p className="mt-3">
138+ Existing tool path behavior is preserved, so a file like
139+ {" "}<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">.executor/tools/demo.ts</code>
140+ {" "}still appears as <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">tools.demo(...)</code>.
141+ </p>
142+ </div>
143+
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+ });
152153 });
153− });
154− }}
155− >
156− Open Local Tools Source
157− </Button>
158− ) : (
159− <Alert variant="warning">
160− No local tools source is active yet. Create a tool in
161− {" "}<code className="rounded bg-muted px-1 py-0.5 font-mono">.executor/tools</code>
162− {" "}and restart the executor session.
163− </Alert>
164− )}
154+ }}
155+ >
156+ Open Local Tools Source
157+ </Button>
158+ ) : (
159+ <Alert variant="warning">
160+ No local tools source is active yet. Create a tool in
161+ {" "}<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">.executor/tools</code>
162+ {" "}and restart the executor session.
163+ </Alert>
164+ )}
165+ </div>
165166 </div>
166− </Card>
167+ </div>
167168 </div>
168169 );
169170 }
plugins/local-tools/react/index.ts+6−3
@@ -1,4 +1,7 @@
1−import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
1+import {
2+ defineExecutorFrontendPlugin,
3+ liveFrontendPluginComponent,
4+} from "@executor/react/plugins";
25
36 import {
47 LocalToolsAddPage,
@@ -13,12 +16,12 @@ export const LocalToolsReactPlugin = defineExecutorFrontendPlugin({ · 8 unmodified lines
1316 {
1417 key: "add",
1518 path: "add",
16− component: LocalToolsAddPage,
19+ component: liveFrontendPluginComponent(() => LocalToolsAddPage),
1720 },
1821 {
1922 key: "detail",
2023 path: "sources/$sourceId",
21− component: LocalToolsDetailRoute,
24+ component: liveFrontendPluginComponent(() => LocalToolsDetailRoute),
2225 },
2326 ],
2427 });
plugins/mcp/react/components.tsx+263−626
@@ -13,12 +13,7 @@ import { · 12 unmodified lines
1313 Alert,
1414 Badge,
1515 Button,
16− Card,
17− cn,
18− IconCheck,
1916 IconPencil,
20− IconSearch,
21− IconSpinner,
2217 Input,
2318 Label,
2419 Select,
@@ -36,8 +31,6 @@ import { · 11 unmodified lines
3631 } from "@executor/plugin-mcp-http";
3732 import {
3833 type McpConnectInput,
39− type McpDiscoverInput,
40− type McpDiscoverResult,
4134 type McpConnectionAuth,
4235 type McpOAuthPopupResult,
4336 type McpStartOAuthInput,
@@ -78,82 +71,6 @@ const defaultMcpInput = (): McpConnectInput => ({ · 34 unmodified lines
7871 },
7972 });
8073
81−type McpQuickPreset = {
82− id: string;
83− name: string;
84− summary: string;
85− input: McpConnectInput;
86−};
87−
88−type 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−
98−const defaultProbeAuthState = (): ProbeAuthState => ({
99− kind: "none",
100− token: "",
101− headerName: "Authorization",
102− prefix: "Bearer ",
103− username: "",
104− password: "",
105− headersText: "",
106−});
107−
108−const 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−
15774 const presetString = (
15875 search: Record<string, unknown>,
15976 key: string,
@@ -250,49 +167,6 @@ const transportFieldsFromInput = (input: McpConnectInput): McpTransportFields => · 90 unmodified lines
250167 headersText: stringifyStringMap(input.headers),
251168 };
252169
253−const 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−
296170 const waitForOauthPopupResult = async (
297171 sessionId: string,
298172 ): Promise<McpOAuthPopupResult> =>
@@ -387,10 +261,6 @@ function McpSourceForm(props: { · 88 unmodified lines
387261 }) {
388262 const installation = useLocalInstallation();
389263 const client = getMcpHttpClient();
390− const discoverSource = useAtomSet(
391− client.mutation("mcp", "discoverSource"),
392− { mode: "promise" },
393− );
394264 const startOAuth = useAtomSet(
395265 client.mutation("mcp", "startOAuth"),
396266 { mode: "promise" },
@@ -415,104 +285,9 @@ function McpSourceForm(props: { · 18 unmodified lines
415285 props.initialValue.auth.kind === "oauth2" ? "connected" : "idle",
416286 );
417287 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− );
438288
439289 const isStdio = transportFields.transport === "stdio";
440290
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−
516291 const runOauth = async () => {
517292 if (installation.status !== "ready") {
518293 throw new Error("Workspace is still loading.");
@@ -591,382 +366,218 @@ function McpSourceForm(props: { · 72 unmodified lines
591366
592367 return (
593368 <div className="space-y-8">
594− {props.mode === "create" && (
595− <Card className="p-6">
596− <div className="text-sm font-medium text-foreground">Presets</div>
597− <div className="mt-3 grid gap-3 sm:grid-cols-2">
598− {mcpQuickPresets.map((preset) => {
599− const selected =
600− preset.input.transport === "stdio"
601− ? transportFields.transport === "stdio"
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− )}
369+ <div className="space-y-6 rounded-lg border border-border bg-card p-6 text-sm ring-1 ring-foreground/[0.04]">
370+ <div className="grid gap-2">
371+ <Label>Name</Label>
372+ <Input
373+ value={name}
374+ onChange={(event) => setName(event.target.value)}
375+ />
376+ </div>
627377
628− {props.mode === "create" && (
629− <Card className="p-6">
630− <div>
631− <div className="flex items-center justify-between gap-3">
632− <div className="text-sm font-medium text-foreground">Discover</div>
633− <Button
634− variant="ghost"
635− onClick={() => setShowProbeAuth((current) => !current)}
636− className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
637− >
638− {showProbeAuth ? "Hide auth" : "Need auth?"}
639− </Button>
640− </div>
378+ <div className="grid gap-2">
379+ <Label>Transport</Label>
380+ <Select
381+ value={transportFields.transport}
382+ onChange={(event) => {
383+ const nextTransport = event.target.value as McpTransportValue;
384+ setTransportFields((current) =>
385+ setMcpTransportFieldsTransport(current, nextTransport)
386+ );
387+ if (nextTransport === "stdio") {
388+ setAuthKind("none");
389+ setOauthAuth(null);
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>
641401
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>
643406 <Input
644− value={discoveryEndpoint}
645− onChange={(event) => setDiscoveryEndpoint(event.target.value)}
646− placeholder="https://mcp.example.com/mcp"
647− className="flex-1 font-mono text-xs"
407+ value={transportFields.command}
408+ onChange={(event) =>
409+ setTransportFields({
410+ ...transportFields,
411+ command: event.target.value,
412+ })}
413+ className="font-mono text-xs"
648414 />
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>
661415 </div>
662416
663− {showProbeAuth && (
664− <div className="mt-3 space-y-3 rounded-lg border border-border bg-muted/20 p-4">
665− <div className="grid gap-2">
666− <Label>Discovery auth</Label>
667− <Select
668− value={probeAuth.kind}
669− onChange={(event) =>
670− setProbeAuth((current) => ({
671− ...current,
672− kind: event.target.value as ProbeAuthState["kind"],
673− }))}
674− >
675− <option value="none">None</option>
676− <option value="bearer">Bearer</option>
677− <option value="basic">Basic</option>
678− <option value="headers">Custom headers</option>
679− </Select>
680− </div>
417+ <div className="grid gap-2">
418+ <Label>Args</Label>
419+ <Textarea
420+ value={transportFields.argsText}
421+ onChange={(event) =>
422+ setTransportFields({
423+ ...transportFields,
424+ argsText: event.target.value,
425+ })}
426+ rows={3}
427+ placeholder='["server.js","--port","8787"]'
428+ />
429+ </div>
681430
682− {probeAuth.kind === "bearer" && (
683− <div className="grid gap-4 md:grid-cols-2">
684− <div className="grid gap-2">
685− <Label>Header name</Label>
686− <Input
687− value={probeAuth.headerName}
688− onChange={(event) =>
689− setProbeAuth((current) => ({ ...current, headerName: event.target.value }))}
690− />
691− </div>
692− <div className="grid gap-2">
693− <Label>Prefix</Label>
694− <Input
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− )}
431+ <div className="grid gap-2">
432+ <Label>Environment</Label>
433+ <Textarea
434+ value={transportFields.envText}
435+ onChange={(event) =>
436+ setTransportFields({
437+ ...transportFields,
438+ envText: event.target.value,
439+ })}
440+ rows={3}
441+ placeholder='{"NODE_ENV":"production"}'
442+ />
443+ </div>
748444
749− {discoveryMessage && (
750− <div className="mt-2 text-xs text-muted-foreground">
751− {discoveryMessage}
752− </div>
753− )}
445+ <div className="grid gap-2 md:col-span-2">
446+ <Label>Working Directory</Label>
447+ <Input
448+ value={transportFields.cwd}
449+ onChange={(event) =>
450+ setTransportFields({
451+ ...transportFields,
452+ cwd: event.target.value,
453+ })}
454+ className="font-mono text-xs"
455+ />
456+ </div>
754457 </div>
755− </Card>
756− )}
757−
758− <Card className="space-y-6 p-6">
759− <div className="grid gap-2">
760− <Label>Name</Label>
761− <Input
762− value={name}
763− onChange={(event) => setName(event.target.value)}
764− />
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>
458+ ) : (
459+ <div className="grid gap-4 md:grid-cols-2">
460+ <div className="grid gap-2 md:col-span-2">
461+ <Label>Endpoint</Label>
462+ <Input
463+ value={endpoint}
464+ onChange={(event) => setEndpoint(event.target.value)}
465+ className="font-mono text-xs"
466+ />
467+ </div>
790468
791− {isStdio ? (
792− <div className="grid gap-4 md:grid-cols-2">
793− <div className="grid gap-2 md:col-span-2">
794− <Label>Command</Label>
795− <Input
796− value={transportFields.command}
797− onChange={(event) =>
798− setTransportFields({
799− ...transportFields,
800− command: event.target.value,
801− })}
802− className="font-mono text-xs"
803− />
804− </div>
469+ <div className="grid gap-2">
470+ <Label>Query Params</Label>
471+ <Textarea
472+ value={transportFields.queryParamsText}
473+ onChange={(event) =>
474+ setTransportFields({
475+ ...transportFields,
476+ queryParamsText: event.target.value,
477+ })}
478+ rows={3}
479+ placeholder='{"transport":"streamable-http"}'
480+ />
481+ </div>
805482
806− <div className="grid gap-2">
807− <Label>Args</Label>
808− <Textarea
809− value={transportFields.argsText}
810− onChange={(event) =>
811− setTransportFields({
812− ...transportFields,
813− argsText: event.target.value,
814− })}
815− rows={3}
816− placeholder='["server.js","--port","8787"]'
817− />
483+ <div className="grid gap-2">
484+ <Label>Headers</Label>
485+ <Textarea
486+ value={transportFields.headersText}
487+ onChange={(event) =>
488+ setTransportFields({
489+ ...transportFields,
490+ headersText: event.target.value,
491+ })}
492+ rows={3}
493+ placeholder='{"x-api-key":"..."}'
494+ />
495+ </div>
818496 </div>
497+ )}
819498
499+ {!isStdio && (
820500 <div className="grid gap-2">
821− <Label>Environment</Label>
822− <Textarea
823− value={transportFields.envText}
824− onChange={(event) =>
825− setTransportFields({
826− ...transportFields,
827− envText: event.target.value,
828− })}
829− rows={3}
830− placeholder='{"NODE_ENV":"production"}'
831− />
832− </div>
833−
834− <div className="grid gap-2 md:col-span-2">
835− <Label>Working Directory</Label>
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− />
501+ <Label>Auth</Label>
502+ <Select
503+ value={authKind}
504+ onChange={(event) => {
505+ const nextKind = event.target.value as McpConnectionAuth["kind"];
506+ setAuthKind(nextKind);
507+ if (nextKind !== "oauth2") {
508+ setOauthAuth(null);
509+ setOauthStatus("idle");
510+ }
511+ }}
512+ >
513+ <option value="none">None</option>
514+ <option value="oauth2">OAuth 2.0</option>
515+ </Select>
856516 </div>
857−
858− <div className="grid gap-2">
859− <Label>Query Params</Label>
860− <Textarea
861− value={transportFields.queryParamsText}
862− onChange={(event) =>
863− setTransportFields({
864− ...transportFields,
865− queryParamsText: event.target.value,
866− })}
867− rows={3}
868− placeholder='{"transport":"streamable-http"}'
869− />
517+ )}
518+
519+ {!isStdio && authKind === "oauth2" && (
520+ <div className="rounded-lg border border-border bg-muted/20 p-4">
521+ <div className="flex flex-wrap items-center justify-between gap-3">
522+ <div>
523+ <div className="text-sm font-medium text-foreground">OAuth</div>
524+ <div className="mt-1 text-xs text-muted-foreground">
525+ Authenticate with the MCP server's built-in OAuth flow.
526+ </div>
527+ </div>
528+ <Button
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+ )}
870550 </div>
551+ )}
871552
872− <div className="grid gap-2">
873− <Label>Headers</Label>
874− <Textarea
875− value={transportFields.headersText}
876− onChange={(event) =>
877− setTransportFields({
878− ...transportFields,
879− headersText: event.target.value,
880− })}
881− rows={3}
882− placeholder='{"x-api-key":"..."}'
883− />
884− </div>
885− </div>
886− )}
553+ {error && (
554+ <Alert variant="destructive">
555+ {error}
556+ </Alert>
557+ )}
887558
888− {!isStdio && (
889− <div className="grid gap-2">
890− <Label>Auth</Label>
891− <Select
892− value={authKind}
893− onChange={(event) => {
894− const nextKind = event.target.value as McpConnectionAuth["kind"];
895− setAuthKind(nextKind);
896− if (nextKind !== "oauth2") {
897− setOauthAuth(null);
898− setOauthStatus("idle");
899− }
559+ <div className="flex items-center justify-end gap-3">
560+ <Button
561+ onClick={() => {
562+ setError(null);
563+ void submitMutation
564+ .mutateAsync(buildInput())
565+ .catch((cause: unknown) =>
566+ setError(cause instanceof Error ? cause.message : String(cause))
567+ );
900568 }}
569+ disabled={submitMutation.status === "pending"}
901570 >
902− <option value="none">None</option>
903− <option value="oauth2">OAuth 2.0</option>
904− </Select>
905− </div>
906− )}
907−
908− {!isStdio && authKind === "oauth2" && (
909− <div className="rounded-lg border border-border bg-muted/20 p-4">
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− )}
571+ {submitMutation.status === "pending"
572+ ? props.mode === "create"
573+ ? "Creating..."
574+ : "Saving..."
575+ : props.mode === "create"
576+ ? "Create Source"
577+ : "Save Changes"}
578+ </Button>
939579 </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>
968580 </div>
969− </Card>
970581 </div>
971582 );
972583 }
@@ -986,27 +597,40 @@ export function McpAddPage() { · 13 unmodified lines
986597 }
987598
988599 return (
989− <McpSourceForm
990− initialValue={initialValue}
991− mode="create"
992− onSubmit={async (input) => {
993− const source = await createSource({
994− path: {
995− workspaceId: installation.data.scopeId,
996− },
997− payload: input,
998− reactivityKeys: {
999− sources: [installation.data.scopeId],
1000− },
1001− });
1002−
1003− startTransition(() => {
1004− void navigation.detail(source.id, {
1005− tab: "model",
1006− });
1007− });
1008− }}
1009− />
600+ <div className="min-h-0 flex-1 overflow-y-auto">
601+ <div className="mx-auto w-full max-w-3xl px-6 py-10">
602+ <div className="mb-8">
603+ <h1 className="font-display text-2xl tracking-tight text-foreground">
604+ Add MCP Source
605+ </h1>
606+ <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
607+ Connect a Model Context Protocol server and import its tools.
608+ </p>
609+ </div>
610+
611+ <McpSourceForm
612+ initialValue={initialValue}
613+ mode="create"
614+ onSubmit={async (input) => {
615+ const source = await createSource({
616+ path: {
617+ workspaceId: installation.data.scopeId,
618+ },
619+ payload: input,
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>
1010634 );
1011635 }
1012636
@@ -1054,32 +678,45 @@ export function McpEditPage(props: { · 41 unmodified lines
1054678 }
1055679
1056680 return (
1057− <McpSourceForm
1058− initialValue={configResult.value}
1059− mode="edit"
1060− onSubmit={async (input) => {
1061− const source = await updateSource({
1062− path: {
1063− workspaceId: installation.data.scopeId,
1064− sourceId: props.source.id,
1065− },
1066− payload: input,
1067− reactivityKeys: {
1068− sources: [installation.data.scopeId],
1069− source: [installation.data.scopeId, props.source.id],
1070− sourceInspection: [installation.data.scopeId, props.source.id],
1071− sourceInspectionTool: [installation.data.scopeId, props.source.id],
1072− sourceDiscovery: [installation.data.scopeId, props.source.id],
1073− },
1074− });
1075−
1076− startTransition(() => {
1077− void navigation.detail(source.id, {
1078− tab: "model",
1079− });
1080− });
1081− }}
1082− />
681+ <div className="min-h-0 flex-1 overflow-y-auto">
682+ <div className="mx-auto w-full max-w-3xl px-6 py-10">
683+ <div className="mb-8">
684+ <h1 className="font-display text-2xl tracking-tight text-foreground">
685+ Edit MCP Source
686+ </h1>
687+ <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
688+ Update the connection settings for {props.source.name}.
689+ </p>
690+ </div>
691+
692+ <McpSourceForm
693+ initialValue={configResult.value}
694+ mode="edit"
695+ onSubmit={async (input) => {
696+ const source = await updateSource({
697+ path: {
698+ workspaceId: installation.data.scopeId,
699+ sourceId: props.source.id,
700+ },
701+ payload: input,
702+ reactivityKeys: {
703+ sources: [installation.data.scopeId],
704+ source: [installation.data.scopeId, props.source.id],
705+ sourceInspection: [installation.data.scopeId, props.source.id],
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>
1083720 );
1084721 }
1085722
plugins/mcp/react/index.ts+7−4
@@ -1,4 +1,7 @@
1−import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
1+import {
2+ defineExecutorFrontendPlugin,
3+ liveFrontendPluginComponent,
4+} from "@executor/react/plugins";
25
36 import {
47 McpAddPage,
@@ -14,17 +17,17 @@ export const McpReactPlugin = defineExecutorFrontendPlugin({ · 9 unmodified lines
1417 {
1518 key: "add",
1619 path: "add",
17− component: McpAddPage,
20+ component: liveFrontendPluginComponent(() => McpAddPage),
1821 },
1922 {
2023 key: "detail",
2124 path: "sources/$sourceId",
22− component: McpDetailRoute,
25+ component: liveFrontendPluginComponent(() => McpDetailRoute),
2326 },
2427 {
2528 key: "edit",
2629 path: "sources/$sourceId/edit",
27− component: McpEditRoute,
30+ component: liveFrontendPluginComponent(() => McpEditRoute),
2831 },
2932 ],
3033 });
plugins/onepassword/react/index.ts+7−2
@@ -1,4 +1,7 @@
1−import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
1+import {
2+ defineExecutorFrontendPlugin,
3+ liveFrontendPluginComponent,
4+} from "@executor/react/plugins";
25 import { ONEPASSWORD_SECRET_STORE_KIND } from "@executor/plugin-onepassword-shared";
36
47 import { OnePasswordSecretStoreCreateForm } from "./components";
@@ -8,6 +11,8 @@ export const OnePasswordReactPlugin = defineExecutorFrontendPlugin({ · 3 unmodified lines
811 displayName: "1Password",
912 secretStore: {
1013 kind: ONEPASSWORD_SECRET_STORE_KIND,
11− CreateStoreForm: OnePasswordSecretStoreCreateForm,
14+ CreateStoreForm: liveFrontendPluginComponent(
15+ () => OnePasswordSecretStoreCreateForm,
16+ ),
1217 },
1318 });
plugins/openapi/react/components.tsx+140−125
@@ -15,7 +15,6 @@ import { · 14 unmodified lines
1515 import {
1616 Alert,
1717 Button,
18− Card,
1918 DocumentPanel,
2019 IconPencil,
2120 Input,
@@ -149,7 +148,7 @@ const Section = (props: { · 127 unmodified lines
149148 children: ReactNode;
150149 }) => (
151150 <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>
153152 {props.children}
154153 </section>
155154 );
@@ -387,7 +386,7 @@ function OpenApiSourceForm(props: { · 231 unmodified lines
387386 };
388387
389388 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]">
391390 <Section title="Connection">
392391 <div className="grid gap-4">
393392 <div className="grid gap-2">
@@ -477,59 +476,47 @@ function OpenApiSourceForm(props: { · 83 unmodified lines
477476 </div>
478477 </Section>
479478
480− <Section title="Preview">
481− <div className="flex items-center gap-3">
482− <Button
483− variant="outline"
484− type="button"
485− onClick={() => {
486− void runPreview({ mode: "manual" });
487− }}
488− disabled={previewMutation.status === "pending" || submitMutation.status === "pending"}
489− >
490− {previewMutation.status === "pending" ? "Previewing..." : "Preview Spec"}
491− </Button>
492− {preview && (
493− <div className="text-xs text-muted-foreground">
494− {preview.operationCount} operations
495− {preview.version ? ` · v${preview.version}` : ""}
479+ {(preview || previewMutation.status === "pending") && (
480+ <Section title="Preview">
481+ {previewMutation.status === "pending" ? (
482+ <div className="text-xs text-muted-foreground">Loading preview...</div>
483+ ) : preview && (
484+ <div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm">
485+ <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
486+ <dt className="font-medium text-foreground">Title</dt>
487+ <dd className="text-muted-foreground">{preview.title ?? "—"}</dd>
488+ <dt className="font-medium text-foreground">Version</dt>
489+ <dd className="text-muted-foreground">{preview.version ?? "—"}</dd>
490+ <dt className="font-medium text-foreground">Operations</dt>
491+ <dd className="text-muted-foreground">{preview.operationCount}</dd>
492+ <dt className="font-medium text-foreground">Base URL</dt>
493+ <dd className="font-mono text-muted-foreground">{preview.baseUrl ?? "—"}</dd>
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+ )}
496516 </div>
497517 )}
498− </div>
499− {preview && (
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>
518+ </Section>
519+ )}
533520
534521 {error && (
535522 <Alert variant="destructive">
@@ -548,7 +535,7 @@ function OpenApiSourceForm(props: { · 12 unmodified lines
548535 {submitMutation.status === "pending" ? props.busyLabel : props.submitLabel}
549536 </Button>
550537 </div>
551− </Card>
538+ </div>
552539 );
553540 }
554541
@@ -564,35 +551,50 @@ export function OpenApiAddSourcePage() { · 9 unmodified lines
564551
565552 if (!workspace.enabled) {
566553 return (
567− <Section title="Add Source">
568− <div className="text-sm text-muted-foreground">Loading workspace...</div>
569− </Section>
554+ <div className="min-h-0 flex-1 overflow-y-auto">
555+ <div className="mx-auto w-full max-w-3xl px-6 py-10">
556+ <div className="text-sm text-muted-foreground">Loading workspace...</div>
557+ </div>
558+ </div>
570559 );
571560 }
572561
573562 return (
574− <OpenApiSourceForm
575− mode="create"
576− workspaceId={workspace.workspaceId}
577− initialValue={initialValue}
578− submitLabel="Create Source"
579− busyLabel="Creating..."
580− onSubmit={async (payload) => {
581− const source = await createSource({
582− path: { workspaceId: workspace.workspaceId },
583− payload,
584− reactivityKeys: {
585− sources: [workspace.workspaceId],
586− },
587− });
588−
589− startTransition(() => {
590− void navigation.detail(source.id, {
591− tab: "model",
592− });
593− });
594− }}
595− />
563+ <div className="min-h-0 flex-1 overflow-y-auto">
564+ <div className="mx-auto w-full max-w-3xl px-6 py-10">
565+ <div className="mb-8">
566+ <h1 className="font-display text-2xl tracking-tight text-foreground">
567+ Add OpenAPI Source
568+ </h1>
569+ <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
570+ Import operations from an OpenAPI specification as callable tools.
571+ </p>
572+ </div>
573+
574+ <OpenApiSourceForm
575+ mode="create"
576+ workspaceId={workspace.workspaceId}
577+ initialValue={initialValue}
578+ submitLabel="Create Source"
579+ busyLabel="Creating..."
580+ onSubmit={async (payload) => {
581+ const source = await createSource({
582+ path: { workspaceId: workspace.workspaceId },
583+ payload,
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>
596598 );
597599 }
598600
@@ -603,9 +605,11 @@ export function OpenApiEditSourcePage(props: { · 4 unmodified lines
603605
604606 if (!workspace.enabled) {
605607 return (
606− <Section title="Edit Source">
607− <div className="text-sm text-muted-foreground">Loading workspace...</div>
608− </Section>
608+ <div className="min-h-0 flex-1 overflow-y-auto">
609+ <div className="mx-auto w-full max-w-3xl px-6 py-10">
610+ <div className="text-sm text-muted-foreground">Loading workspace...</div>
611+ </div>
612+ </div>
609613 );
610614 }
611615
@@ -641,53 +645,64 @@ function OpenApiEditSourcePageReady(props: { · 29 unmodified lines
641645 );
642646
643647 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−
654648 return (
655− <Section title="Edit Source">
656− <div className="text-sm text-muted-foreground">Loading configuration...</div>
657− </Section>
649+ <div className="min-h-0 flex-1 overflow-y-auto">
650+ <div className="mx-auto w-full max-w-3xl px-6 py-10">
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>
658660 );
659661 }
660662
661663 return (
662− <OpenApiSourceForm
663− mode="edit"
664− workspaceId={props.workspaceId}
665− initialValue={inputFromConfig(configResult.value)}
666− submitLabel="Save Changes"
667− busyLabel="Saving..."
668− onSubmit={async (config) => {
669− const source = await updateSource({
670− path: {
671− workspaceId: props.workspaceId,
672− sourceId: props.source.id,
673− },
674− payload: config,
675− reactivityKeys: {
676− sources: [props.workspaceId],
677− source: [props.workspaceId, props.source.id],
678− sourceInspection: [props.workspaceId, props.source.id],
679− sourceInspectionTool: [props.workspaceId, props.source.id],
680− sourceDiscovery: [props.workspaceId, props.source.id],
681− },
682− });
683−
684− startTransition(() => {
685− void navigation.detail(source.id, {
686− tab: "model",
687− });
688− });
689− }}
690− />
664+ <div className="min-h-0 flex-1 overflow-y-auto">
665+ <div className="mx-auto w-full max-w-3xl px-6 py-10">
666+ <div className="mb-8">
667+ <h1 className="font-display text-2xl tracking-tight text-foreground">
668+ Edit OpenAPI Source
669+ </h1>
670+ <p className="mt-1.5 text-sm leading-6 text-muted-foreground">
671+ Update the connection settings for {props.source.name}.
672+ </p>
673+ </div>
674+
675+ <OpenApiSourceForm
676+ mode="edit"
677+ workspaceId={props.workspaceId}
678+ initialValue={inputFromConfig(configResult.value)}
679+ submitLabel="Save Changes"
680+ busyLabel="Saving..."
681+ onSubmit={async (config) => {
682+ const source = await updateSource({
683+ path: {
684+ workspaceId: props.workspaceId,
685+ sourceId: props.source.id,
686+ },
687+ payload: config,
688+ reactivityKeys: {
689+ sources: [props.workspaceId],
690+ source: [props.workspaceId, props.source.id],
691+ sourceInspection: [props.workspaceId, props.source.id],
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>
691706 );
692707 }
693708
plugins/openapi/react/index.ts+7−4
@@ -1,4 +1,7 @@
1−import { defineExecutorFrontendPlugin } from "@executor/react/plugins";
1+import {
2+ defineExecutorFrontendPlugin,
3+ liveFrontendPluginComponent,
4+} from "@executor/react/plugins";
25
36 import {
47 OpenApiAddSourcePage,
@@ -13,17 +16,17 @@ export const OpenApiReactPlugin = defineExecutorFrontendPlugin({ · 8 unmodified lines
1316 {
1417 key: "add",
1518 path: "add",
16− component: OpenApiAddSourcePage,
19+ component: liveFrontendPluginComponent(() => OpenApiAddSourcePage),
1720 },
1821 {
1922 key: "detail",
2023 path: "sources/$sourceId",
21− component: OpenApiDetailRoute,
24+ component: liveFrontendPluginComponent(() => OpenApiDetailRoute),
2225 },
2326 {
2427 key: "edit",
2528 path: "sources/$sourceId/edit",
26− component: OpenApiEditRoute,
29+ component: liveFrontendPluginComponent(() => OpenApiEditRoute),
2730 },
2831 ],
2932 });