1import { ExternalLinkIcon, RefreshCwIcon, SearchXIcon } from "lucide-react";2import { useEffect, useMemo, useState } from "react";3 4import { useAppStore } from "../../../app/store";5import { operationKey } from "../../../app/store.helpers";6import { Button } from
"../../../components/ui/button"
;
7import { Input } from "../../../components/ui/input";
8import { Label } from "../../../components/ui/label";
9import { Switch } from "../../../components/ui/switch";
10import { OperationFeedback } from "../../OperationFeedback";
11import {
12 EntityIcon,
13 SettingsEmptyState,
14 SettingsSection,
15 SettingsStatusPill,
16} from "../SettingsPrimitives";
17
18export function OpenAiNativeConnectorsPage() {
19 const workspaces = useAppStore((s) => s.workspaces);
20 const selectedWorkspaceId = useAppStore((s) => s.selectedWorkspaceId);
21 const workspaceRuntimeById = useAppStore((s) => s.workspaceRuntimeById);
22 const requestOpenAiNativeConnectors = useAppStore((s) => s.requestOpenAiNativeConnectors);
23 const refreshOpenAiNativeConnectors = useAppStore((s) => s.refreshOpenAiNativeConnectors);
24 const setOpenAiNativeConnectorEnabled = useAppStore((s) => s.setOpenAiNativeConnectorEnabled);
25 const operationsByKey = useAppStore((s) => s.operationsByKey);
26
27 const workspace = useMemo(
28 () => workspaces.find((entry) => entry.id === selectedWorkspaceId) ?? workspaces[0] ?? null,
29 [workspaces, selectedWorkspaceId],
30 );
31 const runtime = workspace ? workspaceRuntimeById[workspace.id] : null;
32 const connectors = runtime?.openAiNativeConnectors ?? [];
33 const loading = runtime?.openAiNativeConnectorsLoading === true;
34 const authenticated = runtime?.openAiNativeConnectorsAuthenticated === true;
35 const message = runtime?.openAiNativeConnectorsMessage;
36 const error = runtime?.openAiNativeConnectorsError;
37 const enabledCount = runtime?.openAiNativeConnectorsEnabledIds.length ?? 0;
38 const accessibleCount = connectors.filter((connector) => connector.isAccessible === true).length;
39 const [query, setQuery] = useState("");
40 const [showEnabledOnly, setShowEnabledOnly] = useState(false);
41
42 useEffect(() => {
43 if (!workspace) return;
44 void requestOpenAiNativeConnectors(workspace.id);
45 }, [workspace, requestOpenAiNativeConnectors]);
46
47 const visibleConnectors = useMemo(() => {
48 const normalizedQuery = query.trim().toLowerCase();
49 return connectors.filter((connector) => {
50 if (showEnabledOnly && !connector.isEnabled) return false;
51 if (!normalizedQuery) return true;
52 return (
53 connector.name.toLowerCase().includes(normalizedQuery) ||
54 connector.id.toLowerCase().includes(normalizedQuery) ||
55 connector.description?.toLowerCase().includes(normalizedQuery)
56 );
57 });
58 }, [connectors, query, showEnabledOnly]);
59
60 return (
61 <SettingsSection
62 title="ChatGPT apps for Codex"
63 description="Enable ChatGPT apps from the Codex app-server for the current folder or chat."
64 action={
65 workspace ? (
66 <Button
67 type="button"
68 variant="outline"
69 size="sm"
70 disabled={loading}
71 onClick={() => void refreshOpenAiNativeConnectors(workspace.id)}
72 >
73 <RefreshCwIcon data-icon="inline-start" />
74 Refresh
75 </Button>
76 ) : undefined
77 }
78 >
79 <div className="flex flex-wrap items-center gap-2 px-4 py-3">
80 <SettingsStatusPill tone={authenticated ? "success" : "warning"}>
81 {authenticated ? "Codex authenticated" : "Codex sign-in required"}
82 </SettingsStatusPill>
83 <SettingsStatusPill tone="neutral">{enabledCount} enabled</SettingsStatusPill>
84 <SettingsStatusPill tone="neutral">{accessibleCount} connected</SettingsStatusPill>
85 {message ? <span className="text-xs text-muted-foreground">{message}</span> : null}
86 {error ? <span className="text-xs text-destructive">{error}</span> : null}
87 </div>
88
89 {!workspace ? (
90 <div className="px-4 py-8 text-sm text-muted-foreground">
91 Select a workspace to manage native connectors.
92 </div>
93 ) : connectors.length === 0 ? (
94 <div className="p-4">
95 <SettingsEmptyState
96 icon={<SearchXIcon />}
97 title="No connectors loaded"
98 description="Refresh after signing in to Codex or installing ChatGPT apps."
99 />
100 </div>
101 ) : (
102 <>
103 <div className="flex flex-wrap items-center gap-3 px-4 py-3">
104 <Input
105 value={query}
106 onChange={(event) => setQuery(event.target.value)}
107 placeholder="Search connectors by name, id, or description"
108 aria-label="Search OpenAI native connectors"
109 className="h-8 max-w-md"
110 />
111 <div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
112 <Label htmlFor="connectors-enabled-only" className="cursor-pointer">
113 Enabled only
114 </Label>
115 <Switch
116 id="connectors-enabled-only"
117 checked={showEnabledOnly}
118 aria-label="Show enabled connectors only"
119 onCheckedChange={setShowEnabledOnly}
120 />
121 </div>
122 </div>
123 {visibleConnectors.length === 0 ? (
124 <div className="flex flex-col items-center gap-2 px-4 py-10 text-center text-sm text-muted-foreground">
125 <SearchXIcon className="size-7 opacity-60" />
126 <p>No connectors match the current filters.</p>
127 </div>
128 ) : (
129 visibleConnectors.map((connector) => {
130 const operation =
131 operationsByKey[operationKey("connector", "enabled", workspace.id, connector.id)];
132 return (
133 <article
134 key={connector.id}
135 className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-3 transition-colors hover:bg-foreground/[0.025]"
136 >
137 <div className="flex min-w-0 items-start gap-3">
138 <EntityIcon src={connector.logoUrl} name={connector.name} />
139 <div className="min-w-0 space-y-1">
140 <div className="flex min-w-0 flex-wrap items-center gap-2">
141 <h3 className="truncate text-sm font-medium">{connector.name}</h3>
142 {connector.isWorkspaceConnector ? (
143 <SettingsStatusPill tone="neutral">Workspace</SettingsStatusPill>
144 ) : null}
145 <SettingsStatusPill tone={connector.isAccessible ? "success" : "neutral"}>
146 {connector.isAccessible ? "Connected" : "Directory"}
147 </SettingsStatusPill>
148 </div>
149 <p className="truncate font-mono text-xs text-muted-foreground">
150 {connector.id}
151 </p>
152 {connector.description ? (
153 <p className="line-clamp-2 max-w-3xl text-sm text-muted-foreground">
154 {connector.description}
155 </p>
156 ) : null}
157 {connector.installUrl ? (
158 <a
159 href={connector.installUrl}
160 target="_blank"
161 rel="noreferrer"
162 className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
163 >
164 Manage in ChatGPT
165 <ExternalLinkIcon className="size-3" />
166 </a>
167 ) : null}
168 <OperationFeedback operation={operation} />
169 </div>
170 </div>
171 <div className="flex items-start pt-1">
172 <Switch
173 checked={connector.isEnabled}
174 disabled={!connector.isAccessible || operation?.status === "pending"}
175 aria-label={`Enable ${connector.name}`}
176 onCheckedChange={(enabled) =>
177 void setOpenAiNativeConnectorEnabled(workspace.id, connector.id, enabled)
178 }
179 />
180 </div>
181 </article>
182 );
183 })
184 )}
185 </>
186 )}
187 </SettingsSection>
188 );
189}
190