@devicai/ui 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,109 @@
1
+ import { type JSX } from "react";
2
+ import type { Integration } from "../../api/types";
3
+ import { type DevicTheme } from "../theme";
4
+ import { type IntegrationsState } from "./useIntegrations";
5
+ import { type TenantMcpState } from "./useTenantMcp";
6
+ import "./IntegrationsModal.css";
7
+ export interface IntegrationsPanelProps {
8
+ /** Assistant whose offered apps are shown. */
9
+ assistantId: string;
10
+ /** Tenant of the end user (falls back to the provider's tenantId). */
11
+ tenantId?: string;
12
+ /** Subtenant of the end user (falls back to the provider's subtenantId). */
13
+ subtenantId?: string;
14
+ /** API key override (falls back to the provider's). */
15
+ apiKey?: string;
16
+ /** Base URL override (falls back to the provider's). */
17
+ baseUrl?: string;
18
+ /** Heading text, and the accessible name of the region. @default "Connected apps" */
19
+ title?: string;
20
+ /** Search field placeholder. @default "Search connected apps" */
21
+ searchPlaceholder?: string;
22
+ /** Called after an account is connected or disconnected. */
23
+ onChange?: (integrations: Integration[]) => void;
24
+ /**
25
+ * Colours and font. Same names as the drawer's style options, and the drawer
26
+ * passes its own down — a dialog opening in the default light palette over a
27
+ * themed application is the one thing this must not do.
28
+ *
29
+ * Embedded in a page the same applies for a different reason: the panel
30
+ * renders in its own variables and inherits nothing from the surface around
31
+ * it, so a host with its own palette has to say so here.
32
+ */
33
+ theme?: DevicTheme;
34
+ /**
35
+ * Listing loaded elsewhere (see `useIntegrations`). The drawer already has to
36
+ * load it to decide whether its button exists, and passing it down is what
37
+ * keeps opening the modal from asking for the very same thing again.
38
+ */
39
+ state?: IntegrationsState;
40
+ /**
41
+ * MCP listing loaded elsewhere, same reasoning as `state`. When absent this
42
+ * loads its own — and only while on screen, so an assistant that offers no
43
+ * MCP servers costs one refusal and nothing more.
44
+ */
45
+ mcpState?: TenantMcpState;
46
+ /**
47
+ * Whether the panel is on screen. Renders nothing and asks for nothing while
48
+ * false, without dropping what it already knows — which is how the modal
49
+ * closes without paying for the whole listing again on the next open.
50
+ *
51
+ * @default true
52
+ */
53
+ active?: boolean;
54
+ /**
55
+ * The heading, with the plug icon and the title. Turn it off to put the
56
+ * panel under a heading of your own.
57
+ *
58
+ * @default true
59
+ */
60
+ showHeader?: boolean;
61
+ /** The search field. @default true */
62
+ showSearch?: boolean;
63
+ /**
64
+ * The footer, with the privacy note and the Refresh button. Off, nothing else
65
+ * re-reads the listing on demand — keep `state` yourself and call its
66
+ * `refresh` if you drive it from your own control.
67
+ *
68
+ * @default true
69
+ */
70
+ showFooter?: boolean;
71
+ /**
72
+ * Shown as a close button in the header, and called when Escape is pressed.
73
+ * The modal is what usually passes it; embedded, only pass it if the panel
74
+ * is dismissible.
75
+ */
76
+ onClose?: () => void;
77
+ className?: string;
78
+ /**
79
+ * Render inside the modal's backdrop, in a portal on `document.body`.
80
+ *
81
+ * This is what `IntegrationsModal` is, and that component is the one to use
82
+ * — it exists here because both forms share every line of what follows.
83
+ */
84
+ dialog?: boolean;
85
+ }
86
+ /**
87
+ * Where the END USER of an application manages their *own* third-party
88
+ * accounts: the apps the developer offered to tenants of this assistant, each
89
+ * with the accounts this tenant has connected, and the buttons to add or
90
+ * remove one.
91
+ *
92
+ * The same panel serves both shapes it is asked for. `IntegrationsModal` is
93
+ * this with `dialog`, opened from the drawer's header; on its own it drops into
94
+ * a settings page as an ordinary block, which is the form a host wants when
95
+ * connecting apps is a section of their product rather than an interruption of
96
+ * a chat.
97
+ *
98
+ * Backed by `/api/v1/tenant-integrations`, which resolves the tenant
99
+ * server-side, so what is listed here is only ever this tenant's — never the
100
+ * workspace-wide accounts an admin connected, and never another tenant's.
101
+ *
102
+ * Connecting opens the provider's consent screen in a popup. The popup is
103
+ * opened empty *before* the request that produces its URL, because browsers
104
+ * only honour `window.open` inside the gesture that triggered it: opening it
105
+ * after the round trip is what gets it blocked. When it is blocked anyway, the
106
+ * URL is offered as a link instead.
107
+ */
108
+ export declare function IntegrationsPanel({ assistantId, tenantId, subtenantId, apiKey, baseUrl, title, searchPlaceholder, onChange, theme, state, mcpState, active, showHeader, showSearch, showFooter, onClose, className, dialog, }: IntegrationsPanelProps): JSX.Element | null;
109
+ export default IntegrationsPanel;
@@ -0,0 +1,436 @@
1
+ import { jsxs, jsx } from 'react/jsx-runtime';
2
+ import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { isDarkTheme, themeVars } from '../theme.js';
5
+ import { ConnectFieldsForm } from './ConnectFieldsForm.js';
6
+ import { IntegrationLogo } from './IntegrationLogo.js';
7
+ import { useIntegrations } from './useIntegrations.js';
8
+ import { McpServersSection } from './McpServersSection.js';
9
+ import { useTenantMcp } from './useTenantMcp.js';
10
+
11
+ function PlugIcon() {
12
+ return (jsxs("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [jsx("path", { d: "M12 22v-5" }), jsx("path", { d: "M9 8V2" }), jsx("path", { d: "M15 8V2" }), jsx("path", { d: "M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z" })] }));
13
+ }
14
+ function SearchIcon() {
15
+ return (jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [jsx("circle", { cx: "11", cy: "11", r: "7" }), jsx("path", { d: "m20 20-3.5-3.5" })] }));
16
+ }
17
+ /** A random value tying an OAuth round trip to the window that started it. */
18
+ function newNonce() {
19
+ const c = typeof crypto !== "undefined" ? crypto : undefined;
20
+ if (c?.randomUUID)
21
+ return c.randomUUID();
22
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
23
+ }
24
+ /** How the card describes an app at a glance. */
25
+ function stateOf(integration) {
26
+ if (integration.connected)
27
+ return { key: "connected", label: "Connected" };
28
+ if (integration.accounts.some((a) => a.needsReconnect)) {
29
+ return { key: "reconnect", label: "Needs reconnection" };
30
+ }
31
+ return { key: "disconnected", label: "Not connected" };
32
+ }
33
+ function accountLabel(account) {
34
+ if (!account.connectedAt)
35
+ return account.status.toLowerCase();
36
+ const when = new Date(account.connectedAt);
37
+ if (Number.isNaN(when.getTime()))
38
+ return account.status.toLowerCase();
39
+ return `connected ${when.toLocaleDateString()}`;
40
+ }
41
+ /**
42
+ * The scheme the server would pick, given no explicit choice: the one asking
43
+ * for least setup.
44
+ *
45
+ * Mirrors the engine's own preference deliberately. It is only used to decide
46
+ * whether to open a popup or a form before asking, and being wrong is
47
+ * recoverable — the form sends its scheme explicitly, and a redirect that
48
+ * arrives anyway is still honoured.
49
+ */
50
+ function preferredScheme(schemes) {
51
+ if (!schemes?.length)
52
+ return undefined;
53
+ const friction = (s) => (s.composioManaged ? 0 : 1);
54
+ return [...schemes].sort((a, b) => friction(a) - friction(b))[0];
55
+ }
56
+ function matches(integration, query) {
57
+ const q = query.trim().toLowerCase();
58
+ if (!q)
59
+ return true;
60
+ return (integration.name.toLowerCase().includes(q) ||
61
+ integration.app.toLowerCase().includes(q) ||
62
+ (integration.description ?? "").toLowerCase().includes(q));
63
+ }
64
+ /**
65
+ * Where the END USER of an application manages their *own* third-party
66
+ * accounts: the apps the developer offered to tenants of this assistant, each
67
+ * with the accounts this tenant has connected, and the buttons to add or
68
+ * remove one.
69
+ *
70
+ * The same panel serves both shapes it is asked for. `IntegrationsModal` is
71
+ * this with `dialog`, opened from the drawer's header; on its own it drops into
72
+ * a settings page as an ordinary block, which is the form a host wants when
73
+ * connecting apps is a section of their product rather than an interruption of
74
+ * a chat.
75
+ *
76
+ * Backed by `/api/v1/tenant-integrations`, which resolves the tenant
77
+ * server-side, so what is listed here is only ever this tenant's — never the
78
+ * workspace-wide accounts an admin connected, and never another tenant's.
79
+ *
80
+ * Connecting opens the provider's consent screen in a popup. The popup is
81
+ * opened empty *before* the request that produces its URL, because browsers
82
+ * only honour `window.open` inside the gesture that triggered it: opening it
83
+ * after the round trip is what gets it blocked. When it is blocked anyway, the
84
+ * URL is offered as a link instead.
85
+ */
86
+ function IntegrationsPanel({ assistantId, tenantId, subtenantId, apiKey, baseUrl, title = "Connected apps", searchPlaceholder = "Search connected apps", onChange, theme, state, mcpState, active = true, showHeader = true, showSearch = true, showFooter = true, onClose, className, dialog = false, }) {
87
+ // Hooks cannot be skipped, so the fallback is always built and only fetches
88
+ // when nobody handed a listing down.
89
+ const own = useIntegrations({
90
+ assistantId,
91
+ tenantId,
92
+ subtenantId,
93
+ apiKey,
94
+ baseUrl,
95
+ enabled: active && !state,
96
+ });
97
+ const { integrations, loading, error: loadError, offered: appsOffered, refresh, client, scope, } = state ?? own;
98
+ // Same shape as above: always built (hooks cannot be skipped), fetching only
99
+ // when nobody handed a listing down.
100
+ const ownMcp = useTenantMcp({
101
+ assistantId,
102
+ tenantId,
103
+ subtenantId,
104
+ apiKey,
105
+ baseUrl,
106
+ enabled: active && !mcpState,
107
+ });
108
+ const mcp = mcpState ?? ownMcp;
109
+ /** Errors from connecting or disconnecting, kept apart from load failures. */
110
+ const [actionError, setActionError] = useState(null);
111
+ // An assistant may offer MCP servers and no apps at all, and then the apps
112
+ // endpoint answers "not for you" — an ordinary answer, not a failure. Showing
113
+ // it would put a red panel above a section that is working perfectly. The
114
+ // refusal is still surfaced when there is nothing else on offer, because then
115
+ // an empty dialog with no explanation is worse.
116
+ const appsRefusalIsNoise = !appsOffered && mcp.offered;
117
+ const error = actionError ?? (appsRefusalIsNoise ? null : loadError);
118
+ /** App slug with a connect/disconnect in flight, so only its card is busy. */
119
+ const [busyApp, setBusyApp] = useState(null);
120
+ /** Authorization URL surfaced as a link when the popup was blocked. */
121
+ const [blockedUrl, setBlockedUrl] = useState(null);
122
+ /** The app whose credentials are being asked for, and every way it can be
123
+ * connected. `setup` is present when the server named what was missing. */
124
+ const [setupPrompt, setSetupPrompt] = useState(null);
125
+ /**
126
+ * How each offered app can be connected, read once the listing arrives.
127
+ *
128
+ * Loaded ahead of the click rather than on it: knowing whether an app needs
129
+ * a browser is what decides between opening a popup and opening a form, and
130
+ * that decision has to be made *inside* the user's gesture — a popup opened
131
+ * after an await is blocked.
132
+ */
133
+ const [authByApp, setAuthByApp] = useState({});
134
+ /** Failure from the last submit, shown inside the credentials dialog. */
135
+ const [formError, setFormError] = useState(null);
136
+ const [query, setQuery] = useState("");
137
+ const visible = useMemo(() => integrations.filter((i) => matches(i, query)), [integrations, query]);
138
+ // Resolved from the listing rather than stored in the prompt: the dialog
139
+ // stays open across a refresh, and a copy taken when it opened would go
140
+ // stale — showing "Not connected" on an app that just connected.
141
+ const promptIntegration = useMemo(() => integrations.find((i) => i.app === setupPrompt?.app), [integrations, setupPrompt?.app]);
142
+ // One request per offered app, once the listing is in. They are small,
143
+ // cached server-side, and nothing waits on them: a click that lands before
144
+ // its answer simply falls back to asking the server, as it did before.
145
+ useEffect(() => {
146
+ if (!active || !client || !integrations.length)
147
+ return;
148
+ let cancelled = false;
149
+ void Promise.all(integrations.map(async (integration) => {
150
+ try {
151
+ const { schemes } = await client.getIntegrationAuth(integration.app, scope);
152
+ if (!cancelled && schemes?.length) {
153
+ setAuthByApp((prev) => ({ ...prev, [integration.app]: schemes }));
154
+ }
155
+ }
156
+ catch {
157
+ // Not knowing is survivable — the click path handles it.
158
+ }
159
+ }));
160
+ return () => {
161
+ cancelled = true;
162
+ };
163
+ }, [active, client, integrations, scope]);
164
+ // Report the listing without making the caller's identity part of the
165
+ // dependency: an inline arrow would fire this on every render.
166
+ const onChangeRef = useRef(onChange);
167
+ onChangeRef.current = onChange;
168
+ useEffect(() => {
169
+ if (integrations.length)
170
+ onChangeRef.current?.(integrations);
171
+ }, [integrations]);
172
+ // Reopening starts clean, and re-reads: accounts may have been connected or
173
+ // revoked elsewhere since the last look.
174
+ const wasOpenRef = useRef(false);
175
+ const openedBeforeRef = useRef(false);
176
+ useEffect(() => {
177
+ if (active && !wasOpenRef.current) {
178
+ setBlockedUrl(null);
179
+ setActionError(null);
180
+ setSetupPrompt(null);
181
+ setQuery("");
182
+ // The very first open of an uncontrolled modal is already covered by the
183
+ // hook switching on; asking again here would double every first open.
184
+ if (state || openedBeforeRef.current)
185
+ void refresh();
186
+ if (mcpState || openedBeforeRef.current)
187
+ void mcp.refresh();
188
+ openedBeforeRef.current = true;
189
+ }
190
+ wasOpenRef.current = active;
191
+ // `state.refresh` is stable per scope; re-running on every render of the
192
+ // owner is exactly what this must not do.
193
+ // eslint-disable-next-line react-hooks/exhaustive-deps
194
+ }, [active]);
195
+ // Escape closes — the credentials dialog first, when one is open. Closing
196
+ // everything from under a half-typed key would be its own small disaster.
197
+ //
198
+ // Embedded in a page there is usually nothing to close after that, and the
199
+ // key is left alone rather than swallowed: a panel that is part of the page
200
+ // has no business answering for the whole document.
201
+ useEffect(() => {
202
+ if (!active)
203
+ return;
204
+ const onKey = (e) => {
205
+ if (e.key !== "Escape")
206
+ return;
207
+ if (setupPrompt) {
208
+ setSetupPrompt(null);
209
+ setFormError(null);
210
+ return;
211
+ }
212
+ onClose?.();
213
+ };
214
+ document.addEventListener("keydown", onKey);
215
+ return () => document.removeEventListener("keydown", onKey);
216
+ }, [active, onClose, setupPrompt]);
217
+ /** The round trip currently in flight, if any. */
218
+ const pendingRef = useRef(null);
219
+ const popupRef = useRef(null);
220
+ const pollRef = useRef(null);
221
+ const finishConnect = useCallback(() => {
222
+ if (pollRef.current !== null) {
223
+ window.clearInterval(pollRef.current);
224
+ pollRef.current = null;
225
+ }
226
+ pendingRef.current = null;
227
+ popupRef.current = null;
228
+ setBusyApp(null);
229
+ void refresh(true);
230
+ }, [refresh]);
231
+ // The callback page tells us it is done. Treat the message as a nudge, never
232
+ // as the result: what is displayed comes from re-reading the server, so a
233
+ // forged message can at worst cause one redundant fetch.
234
+ useEffect(() => {
235
+ if (!active)
236
+ return;
237
+ const onMessage = (event) => {
238
+ const data = event.data;
239
+ if (!data || data.source !== "devic")
240
+ return;
241
+ if (data.type !== "integration-connected")
242
+ return;
243
+ const pending = pendingRef.current;
244
+ if (!pending || data.returnTo !== pending.returnTo)
245
+ return;
246
+ popupRef.current?.close();
247
+ finishConnect();
248
+ };
249
+ window.addEventListener("message", onMessage);
250
+ return () => window.removeEventListener("message", onMessage);
251
+ }, [active, finishConnect]);
252
+ // Stop polling if the modal goes away mid-flow.
253
+ useEffect(() => () => {
254
+ if (pollRef.current !== null)
255
+ window.clearInterval(pollRef.current);
256
+ }, []);
257
+ /**
258
+ * Connects an app, asking for credentials only if it needs them.
259
+ *
260
+ * Optimistic: the request goes out first, and the form appears only when the
261
+ * server answers that something is missing. Most apps take an API key rather
262
+ * than an OAuth round trip, so this is the path that used to fail outright.
263
+ */
264
+ const handleConnect = async (integration, values) => {
265
+ if (!client || (busyApp && busyApp !== integration.app))
266
+ return;
267
+ setActionError(null);
268
+ setBlockedUrl(null);
269
+ // Ask before doing anything when the app is known to take credentials and
270
+ // no browser is involved. Connecting first would flash an empty popup and
271
+ // spend a request that can only fail — the user has not been asked yet.
272
+ const known = authByApp[integration.app];
273
+ const scheme = values
274
+ ? known?.find((s) => s.mode === values.authScheme)
275
+ : preferredScheme(known);
276
+ if (!values && scheme && !scheme.redirect && scheme.accountFields.length) {
277
+ setSetupPrompt({ app: integration.app, schemes: known });
278
+ return;
279
+ }
280
+ setBusyApp(integration.app);
281
+ const nonce = newNonce();
282
+ const returnTo = `${window.location.origin}/?devic_oauth=${nonce}`;
283
+ // Opened empty inside the click, navigated once the URL is known — browsers
284
+ // only honour `window.open` inside the gesture that triggered it. Skipped
285
+ // when the scheme in hand needs no browser at all.
286
+ const popup = scheme && !scheme.redirect
287
+ ? null
288
+ : window.open("", "devic-oauth", "width=520,height=680,menubar=no,toolbar=no");
289
+ try {
290
+ const { connected, authorizationUrl } = await client.connectIntegration(integration.app, { ...scope, returnTo, ...values });
291
+ // Nothing to authorise: the key the user typed is the account. Close the
292
+ // window that was opened in case it was needed and re-read the listing.
293
+ if (connected || !authorizationUrl) {
294
+ popup?.close();
295
+ setSetupPrompt(null);
296
+ finishConnect();
297
+ return;
298
+ }
299
+ pendingRef.current = { app: integration.app, returnTo };
300
+ setSetupPrompt(null);
301
+ if (popup && !popup.closed) {
302
+ popupRef.current = popup;
303
+ popup.location.href = authorizationUrl;
304
+ // The user may close the popup without the callback ever posting back
305
+ // — a cancelled consent screen, or a provider that lands somewhere
306
+ // else. Watching for the close is what keeps the card from staying
307
+ // busy forever.
308
+ pollRef.current = window.setInterval(() => {
309
+ if (popup.closed)
310
+ finishConnect();
311
+ }, 700);
312
+ }
313
+ else {
314
+ // Blocked (Safari, in-app browsers, extensions): hand over the URL.
315
+ setBlockedUrl({ app: integration.app, url: authorizationUrl });
316
+ setBusyApp(null);
317
+ }
318
+ }
319
+ catch (err) {
320
+ popup?.close();
321
+ pendingRef.current = null;
322
+ setBusyApp(null);
323
+ const message = err instanceof Error ? err.message : String(err);
324
+ const setup = err?.setupRequired;
325
+ if (setup) {
326
+ await openSetupForm(integration, setup);
327
+ return;
328
+ }
329
+ // While the form is up its own failures belong in it, next to the fields
330
+ // that caused them — a message behind a dialog is a message nobody reads.
331
+ if (setupPrompt?.app === integration.app)
332
+ setFormError(message);
333
+ else
334
+ setActionError(message);
335
+ }
336
+ };
337
+ /**
338
+ * Opens the credentials form for an app that needs one.
339
+ *
340
+ * `stage: "app"` is not the end user's to fix — it means the developer who
341
+ * embedded this widget has not registered an application with the provider
342
+ * yet. Asking a stranger for someone else's client secret would be both
343
+ * useless and a good way to teach them to hand credentials to a form, so it
344
+ * is reported as unavailable instead.
345
+ */
346
+ const openSetupForm = async (integration, setup) => {
347
+ if (setup.stage === "app") {
348
+ setActionError(`${integration.name} is not available yet — it still needs to be set up ` +
349
+ `by the app's provider.`);
350
+ return;
351
+ }
352
+ try {
353
+ const schemes = authByApp[integration.app] ??
354
+ (await client.getIntegrationAuth(integration.app, scope)).schemes;
355
+ if (!schemes.length)
356
+ throw new Error(setup.message);
357
+ setAuthByApp((prev) => ({ ...prev, [integration.app]: schemes }));
358
+ setFormError(null);
359
+ setSetupPrompt({ app: integration.app, setup, schemes });
360
+ }
361
+ catch (err) {
362
+ setActionError(err instanceof Error ? err.message : String(err));
363
+ }
364
+ };
365
+ const handleDisconnect = async (app, account) => {
366
+ if (!client || busyApp)
367
+ return;
368
+ setBusyApp(app);
369
+ setActionError(null);
370
+ try {
371
+ await client.disconnectIntegration(account.id, scope);
372
+ await refresh(true);
373
+ }
374
+ catch (err) {
375
+ setActionError(err instanceof Error ? err.message : String(err));
376
+ }
377
+ finally {
378
+ setBusyApp(null);
379
+ }
380
+ };
381
+ if (!active)
382
+ return null;
383
+ const dark = isDarkTheme(theme);
384
+ const panel = (
385
+ // The variables go here rather than on the backdrop, because this is the
386
+ // one element both shapes have: as a dialog it renders in a portal that
387
+ // inherits nothing from the drawer that opened it, and embedded it must
388
+ // not inherit the host's either — the colours it draws in are the ones
389
+ // `theme` names, and nothing else.
390
+ jsxs("div", { className: [
391
+ "devic-int-panel",
392
+ dialog ? "devic-int-modal" : "",
393
+ className ?? "",
394
+ ]
395
+ .filter(Boolean)
396
+ .join(" "), style: themeVars(theme), "data-dark": dark, role: dialog ? "dialog" : "region", "aria-modal": dialog || undefined, "aria-label": title, onClick: dialog ? (e) => e.stopPropagation() : undefined, children: [showHeader && (jsxs("div", { className: "devic-int-header", children: [jsxs("h3", { className: "devic-int-title", children: [jsx(PlugIcon, {}), title] }), onClose && (jsx("button", { className: "devic-int-close", onClick: onClose, type: "button", "aria-label": "Close", children: "\u00D7" }))] })), showSearch && (jsxs("div", { className: "devic-int-search", children: [jsx(SearchIcon, {}), jsx("input", { type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: searchPlaceholder, "aria-label": searchPlaceholder, autoComplete: "off" })] })), jsxs("div", { className: "devic-int-body", children: [error && jsx("div", { className: "devic-int-error", children: error }), blockedUrl && (jsxs("div", { className: "devic-int-notice", children: ["Your browser blocked the pop-up.", " ", jsx("a", { href: blockedUrl.url, target: "_blank", rel: "noopener noreferrer", onClick: () => {
397
+ pendingRef.current = null;
398
+ setBlockedUrl(null);
399
+ }, children: "Open the authorisation page" }), " ", "and come back \u2014 then use Refresh."] })), loading && integrations.length === 0 ? (
400
+ // Silent while the MCP section is what this assistant offers: a
401
+ // spinner labelled "Loading apps" over a list of servers describes
402
+ // something that is not happening.
403
+ appsRefusalIsNoise ? null : (jsx("div", { className: "devic-int-loading", children: "Loading apps\u2026" }))) : integrations.length === 0 ? (
404
+ // Silent when MCP servers are the whole offer: "no apps available"
405
+ // over a list of servers reads as a broken panel.
406
+ mcp.offered ? null : (jsx("div", { className: "devic-int-empty", children: "No apps available here yet." }))) : visible.length === 0 ? (jsxs("div", { className: "devic-int-empty", children: ["No apps match \u201C", query.trim(), "\u201D."] })) : (jsx("div", { className: "devic-int-grid", children: visible.map((integration) => {
407
+ const cardState = stateOf(integration);
408
+ const busy = busyApp === integration.app;
409
+ return (jsxs("div", { className: "devic-int-card", "data-state": cardState.key, children: [jsxs("div", { className: "devic-int-card-head", children: [jsx(IntegrationLogo, { integration: integration }), jsxs("span", { className: "devic-int-state", children: [jsx("span", { className: "devic-int-dot", "data-ok": cardState.key === "connected", "data-off": cardState.key === "disconnected", "aria-hidden": "true" }), cardState.label] })] }), jsx("div", { className: "devic-int-name", title: integration.name, children: integration.name }), integration.description && (jsx("div", { className: "devic-int-description", title: integration.description, children: integration.description })), jsx("button", { type: "button", className: `devic-int-btn devic-int-btn-block${cardState.key === "connected"
410
+ ? ""
411
+ : " devic-int-btn-primary"}`, onClick: () => handleConnect(integration), disabled: busy || !!busyApp, title: cardState.key === "connected"
412
+ ? "Sign in with a different account. The one connected now is replaced."
413
+ : undefined, children: busy
414
+ ? "Waiting…"
415
+ : cardState.key === "disconnected"
416
+ ? "Connect"
417
+ : cardState.key === "reconnect"
418
+ ? "Reconnect"
419
+ : // Not "Add account": one account per app is all
420
+ // the assistant can use, and connecting again
421
+ // retires the previous one.
422
+ "Switch account" }), integration.accounts.length > 0 && (jsx("ul", { className: "devic-int-accounts", children: integration.accounts.map((account) => (jsxs("li", { className: "devic-int-account", children: [jsx("span", { className: "devic-int-dot", "data-ok": !account.needsReconnect, "aria-hidden": "true" }), jsxs("span", { className: "devic-int-account-label", children: [accountLabel(account), account.needsReconnect && (jsxs("span", { className: "devic-int-account-warn", children: [" ", "\u00B7 reconnect required"] }))] }), jsx("button", { type: "button", className: "devic-int-unlink", onClick: () => handleDisconnect(integration.app, account), disabled: !!busyApp, title: "Disconnect this account", "aria-label": `Disconnect ${integration.name}`, children: "\u00D7" })] }, account.id))) }))] }, integration.app));
423
+ }) }))] }), mcp.offered && (jsx("div", { className: "devic-int-body devic-int-body-secondary", children: jsx(McpServersSection, { state: mcp, theme: theme, query: query }) })), showFooter && (jsxs("div", { className: "devic-int-footer", children: [jsx("span", { children: "Only you can see and use the accounts you connect here." }), jsx("button", { type: "button", className: "devic-int-btn devic-int-btn-small", onClick: () => {
424
+ void refresh(true);
425
+ void mcp.refresh();
426
+ }, disabled: loading || mcp.loading || !!busyApp, children: "Refresh" })] })), setupPrompt && promptIntegration && (jsx(ConnectFieldsForm, { integration: promptIntegration, schemes: setupPrompt.schemes, initialScheme: setupPrompt.setup?.authScheme, onlyFields: setupPrompt.setup?.fields, submitting: busyApp === setupPrompt.app, error: formError, theme: theme, onCancel: () => {
427
+ setSetupPrompt(null);
428
+ setFormError(null);
429
+ }, onSubmit: (values) => handleConnect(promptIntegration, values) }))] }));
430
+ if (!dialog)
431
+ return panel;
432
+ return createPortal(jsx("div", { className: "devic-int-overlay", onClick: onClose, children: panel }), document.body);
433
+ }
434
+
435
+ export { IntegrationsPanel };
436
+ //# sourceMappingURL=IntegrationsPanel.js.map