mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
[codex] fix connector refresh propagation (#3723)
* fix connector refresh propagation * address connector refresh review feedback * bump vela cli to 0.0.12 * chore(nix): refresh pnpm deps hash --------- Co-authored-by: open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
parent
57117c23c6
commit
02a1a5a537
@@ -23,7 +23,7 @@ import {
|
||||
trackFileUploadResult,
|
||||
} from '../analytics/events';
|
||||
import { deriveUploadCohort } from '../analytics/upload-tracking';
|
||||
import { projectRawUrl, uploadProjectFiles, openFolderDialog, fetchConnectors } from "../providers/registry";
|
||||
import { projectRawUrl, uploadProjectFiles, openFolderDialog } from "../providers/registry";
|
||||
import { patchProject } from "../state/projects";
|
||||
import { fetchMcpServers } from "../state/mcp";
|
||||
import type { McpServerConfig, McpTemplate } from "../state/mcp";
|
||||
@@ -59,6 +59,8 @@ import {
|
||||
import { CaretFloatingLayer } from './composer/CaretFloatingLayer';
|
||||
import { ANNOTATION_EVENT, type AnnotationEventDetail } from "./PreviewDrawOverlay";
|
||||
import { DesignSystemSwitchPicker } from "./DesignSystemSwitchPicker";
|
||||
import { listenForConnectorsChanged } from './connectors-events';
|
||||
import { fetchConnectorCatalogSnapshot } from './connectors-state';
|
||||
|
||||
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
|
||||
|
||||
@@ -576,7 +578,7 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
||||
useEffect(() => {
|
||||
if (!composerEngaged) return;
|
||||
let cancelled = false;
|
||||
void fetchConnectors().then((rows) => {
|
||||
void fetchConnectorCatalogSnapshot().then((rows) => {
|
||||
if (cancelled) return;
|
||||
setConnectors(rows.filter((connector) => connector.status === 'connected'));
|
||||
});
|
||||
@@ -585,6 +587,21 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
||||
};
|
||||
}, [composerEngaged]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!composerEngaged) return;
|
||||
let cancelled = false;
|
||||
async function refreshConnectors() {
|
||||
const rows = await fetchConnectorCatalogSnapshot({ refreshDiscovery: true });
|
||||
if (cancelled) return;
|
||||
setConnectors(rows.filter((connector) => connector.status === 'connected'));
|
||||
}
|
||||
const stopListening = listenForConnectorsChanged(() => void refreshConnectors());
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopListening();
|
||||
};
|
||||
}, [composerEngaged]);
|
||||
|
||||
// Composer-side plugin list: hide bundled atoms (pipeline-only). Keep
|
||||
// the full installed list available even when the project was created
|
||||
// from a pinned plugin, so users can switch or layer different plugin
|
||||
|
||||
@@ -25,11 +25,15 @@ import {
|
||||
isTrustedConnectorCallbackOrigin,
|
||||
sortConnectorsForSearch,
|
||||
} from './EntryView';
|
||||
import {
|
||||
CONNECTOR_CALLBACK_MESSAGE_TYPE,
|
||||
notifyConnectorsChanged,
|
||||
} from './connectors-events';
|
||||
import { hasConnectorStatusChanges } from './connectors-state';
|
||||
import { ConnectorLogo, useResolvedTheme } from './ConnectorLogo';
|
||||
import { Icon } from './Icon';
|
||||
import { CenteredLoader } from './Loading';
|
||||
|
||||
const CONNECTOR_CALLBACK_MESSAGE_TYPE = 'open-design:connector-connected';
|
||||
const CONNECTOR_AUTH_PENDING_STORAGE_KEY = 'od-connectors-authorization-pending';
|
||||
const CONNECTOR_AUTH_PENDING_POLL_MS = 2_000;
|
||||
const CONNECTOR_TOOL_PREVIEW_LIMIT = 50;
|
||||
@@ -450,15 +454,22 @@ export function ConnectorsBrowser({
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>(DEFAULT_PROVIDER_TAB_ID);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const searchTrackedRef = useRef(false);
|
||||
const connectorsRef = useRef(connectors);
|
||||
const logoTheme = useResolvedTheme();
|
||||
const toolPreviewRetryToken = `${composioConfigured ? 'configured' : 'unconfigured'}:${String(catalogRefreshKey)}`;
|
||||
|
||||
useEffect(() => {
|
||||
connectorsRef.current = connectors;
|
||||
}, [connectors]);
|
||||
|
||||
const reloadConnectorStatuses = useCallback(async () => {
|
||||
const statuses = await fetchConnectorStatuses();
|
||||
const statusChanged = hasConnectorStatusChanges(connectorsRef.current, statuses);
|
||||
setConnectors((curr) => applyConnectorStatuses(curr, statuses));
|
||||
setConnectorAuthorizationPending((curr) => updateConnectorAuthorizationPendingFromStatuses(curr, statuses));
|
||||
setConnectorAuthorizationError((curr) => clearConnectorAuthorizationErrorsForConnected(curr, statuses));
|
||||
setConnectorAuthorizationCancelFailed((curr) => clearConnectorAuthorizationCancelFailuresForConnected(curr, statuses));
|
||||
if (statusChanged) notifyConnectorsChanged();
|
||||
return statuses;
|
||||
}, []);
|
||||
|
||||
@@ -590,13 +601,23 @@ export function ConnectorsBrowser({
|
||||
// user closed the auth flow without completing it — auto-cancel so the
|
||||
// card recovers to its default state instead of staying stuck loading.
|
||||
useEffect(() => {
|
||||
async function onFocus() {
|
||||
async function refreshAfterReturn() {
|
||||
const pendingBeforeReload = connectorAuthorizationPendingRef.current;
|
||||
const statuses = await reloadConnectorStatuses();
|
||||
await cancelStaleAuthorizations(pendingBeforeReload, statuses);
|
||||
}
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => window.removeEventListener('focus', onFocus);
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
void refreshAfterReturn();
|
||||
}
|
||||
window.addEventListener('focus', refreshAfterReturn);
|
||||
window.addEventListener('pageshow', refreshAfterReturn);
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => {
|
||||
window.removeEventListener('focus', refreshAfterReturn);
|
||||
window.removeEventListener('pageshow', refreshAfterReturn);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
};
|
||||
}, [reloadConnectorStatuses, cancelStaleAuthorizations]);
|
||||
|
||||
// The local Composio API-key state is authoritative for masking. Cached
|
||||
@@ -669,6 +690,7 @@ export function ConnectorsBrowser({
|
||||
const result = await connectConnector(connectorId);
|
||||
updateConnector(result.connector);
|
||||
if (result.connector && !result.error) {
|
||||
if (result.connector.status === 'connected') notifyConnectorsChanged();
|
||||
setConnectorAuthorizationPending((curr) => updateConnectorAuthorizationPendingFromConnectResponse(curr, {
|
||||
connector: result.connector!,
|
||||
...(result.auth === undefined ? {} : { auth: result.auth }),
|
||||
@@ -709,6 +731,7 @@ export function ConnectorsBrowser({
|
||||
});
|
||||
try {
|
||||
updateConnector(await disconnectConnector(connectorId));
|
||||
notifyConnectorsChanged();
|
||||
onConnectorAuthResult?.({
|
||||
connectorId,
|
||||
action: 'disconnect',
|
||||
|
||||
@@ -64,6 +64,8 @@ import type {
|
||||
} from '../types';
|
||||
import { decideAutoOpenAfterWrite } from './auto-open-file';
|
||||
import { ChatPane } from './ChatPane';
|
||||
import { notifyConnectorsChanged } from './connectors-events';
|
||||
import { connectorAuthSnapshotChanged } from './connectors-state';
|
||||
import { FileWorkspace } from './FileWorkspace';
|
||||
import { Icon, type IconName } from './Icon';
|
||||
import { Spinner } from './Loading';
|
||||
@@ -315,6 +317,8 @@ export function DesignSystemCreationFlow({
|
||||
const [githubAuthorizationUrl, setGithubAuthorizationUrl] = useState<string | null>(null);
|
||||
const githubConnectorRefreshId = useRef(0);
|
||||
const githubConnectorRequestInFlight = useRef(false);
|
||||
const githubConnectorRef = useRef<ConnectorDetail | null>(null);
|
||||
const githubConnectorLoadedRef = useRef(false);
|
||||
const embedded = chrome === 'embedded';
|
||||
|
||||
// DS create page_view (v2 doc). Only fires for the standalone
|
||||
@@ -367,6 +371,8 @@ export function DesignSystemCreationFlow({
|
||||
githubConnectorRefreshId.current += 1;
|
||||
githubConnectorRequestInFlight.current = false;
|
||||
setGithubConnector(null);
|
||||
githubConnectorRef.current = null;
|
||||
githubConnectorLoadedRef.current = false;
|
||||
setGithubConnectorLoading(false);
|
||||
setGithubConnectorError(null);
|
||||
setGithubAuthorizationPending(false);
|
||||
@@ -381,7 +387,13 @@ export function DesignSystemCreationFlow({
|
||||
try {
|
||||
const { connector, timedOut } = await fetchGithubConnectorStatusWithTimeout();
|
||||
if (githubConnectorRefreshId.current !== refreshId) return;
|
||||
const statusChanged =
|
||||
githubConnectorLoadedRef.current &&
|
||||
connectorAuthSnapshotChanged(githubConnectorRef.current, connector);
|
||||
setGithubConnector(connector);
|
||||
githubConnectorRef.current = connector;
|
||||
githubConnectorLoadedRef.current = true;
|
||||
if (statusChanged) notifyConnectorsChanged();
|
||||
if (connector?.status === 'connected') {
|
||||
setGithubAuthorizationPending(false);
|
||||
setGithubAuthorizationUrl(null);
|
||||
@@ -439,10 +451,15 @@ export function DesignSystemCreationFlow({
|
||||
try {
|
||||
const result = await connectConnector(GITHUB_CONNECTOR_ID);
|
||||
if (result.error) setGithubConnectorError(result.error);
|
||||
if (result.connector) setGithubConnector(result.connector);
|
||||
if (result.connector) {
|
||||
setGithubConnector(result.connector);
|
||||
githubConnectorRef.current = result.connector;
|
||||
githubConnectorLoadedRef.current = true;
|
||||
}
|
||||
if (result.auth?.redirectUrl) setGithubAuthorizationUrl(result.auth.redirectUrl);
|
||||
if (isPendingConnectorAuth(result.auth)) setGithubAuthorizationPending(true);
|
||||
if (result.auth?.kind === 'connected' || result.connector?.status === 'connected') {
|
||||
notifyConnectorsChanged();
|
||||
setGithubConnectorError(null);
|
||||
setGithubAuthorizationPending(false);
|
||||
setGithubAuthorizationUrl(null);
|
||||
@@ -460,7 +477,11 @@ export function DesignSystemCreationFlow({
|
||||
setGithubConnectorError(null);
|
||||
try {
|
||||
const connector = await disconnectConnector(GITHUB_CONNECTOR_ID);
|
||||
const statusChanged = connector != null && connectorAuthSnapshotChanged(githubConnectorRef.current, connector);
|
||||
setGithubConnector(connector);
|
||||
githubConnectorRef.current = connector;
|
||||
githubConnectorLoadedRef.current = true;
|
||||
if (statusChanged) notifyConnectorsChanged();
|
||||
setGithubAuthorizationPending(false);
|
||||
setGithubAuthorizationUrl(null);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import type {
|
||||
ConnectorDetail,
|
||||
ConnectorStatusResponse,
|
||||
} from '@open-design/contracts';
|
||||
import type { ConnectorDetail } from '@open-design/contracts';
|
||||
import type { OpenDesignHostProjectImportSuccess } from '@open-design/host';
|
||||
import {
|
||||
DEFAULT_AUDIO_MODEL,
|
||||
@@ -32,9 +29,10 @@ import { EntryShell } from './EntryShell';
|
||||
import type { IntegrationTab } from './IntegrationsView';
|
||||
import type { CreateInput, ImportClaudeDesignOutcome } from './NewProjectPanel';
|
||||
import {
|
||||
fetchConnectors,
|
||||
fetchConnectorStatuses,
|
||||
} from '../providers/registry';
|
||||
CONNECTOR_CALLBACK_MESSAGE_TYPE,
|
||||
listenForConnectorsChanged,
|
||||
} from './connectors-events';
|
||||
import { fetchConnectorCatalogSnapshot } from './connectors-state';
|
||||
import type {
|
||||
PluginShareAction,
|
||||
PluginShareProjectOutcome,
|
||||
@@ -121,8 +119,6 @@ interface Props {
|
||||
onCompleteOnboarding: () => void;
|
||||
}
|
||||
|
||||
const CONNECTOR_CALLBACK_MESSAGE_TYPE = 'open-design:connector-connected';
|
||||
|
||||
export function isTrustedConnectorCallbackOrigin(origin: string, currentOrigin?: string): boolean {
|
||||
const expectedOrigin = currentOrigin ?? (typeof window === 'undefined' ? '' : window.location.origin);
|
||||
if (origin === expectedOrigin) return true;
|
||||
@@ -135,24 +131,6 @@ export function isTrustedConnectorCallbackOrigin(origin: string, currentOrigin?:
|
||||
}
|
||||
}
|
||||
|
||||
function applyConnectorStatuses(
|
||||
current: ConnectorDetail[],
|
||||
statuses: ConnectorStatusResponse['statuses'],
|
||||
): ConnectorDetail[] {
|
||||
if (!Object.keys(statuses).length) return current;
|
||||
return current.map((connector) => {
|
||||
const next = statuses[connector.id];
|
||||
if (!next) return connector;
|
||||
const { accountLabel: _accountLabel, lastError: _lastError, ...base } = connector;
|
||||
return {
|
||||
...base,
|
||||
status: next.status,
|
||||
...(next.accountLabel === undefined ? {} : { accountLabel: next.accountLabel }),
|
||||
...(next.lastError === undefined ? {} : { lastError: next.lastError }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function sortConnectorsForDisplay(connectors: ConnectorDetail[]): ConnectorDetail[] {
|
||||
return [...connectors].sort((a, b) => {
|
||||
const aConnected = a.status === 'connected';
|
||||
@@ -282,9 +260,8 @@ export function EntryView({
|
||||
const [connectors, setConnectors] = useState<ConnectorDetail[]>([]);
|
||||
const [connectorsLoading, setConnectorsLoading] = useState(false);
|
||||
|
||||
const reloadConnectorStatuses = useCallback(async () => {
|
||||
const statuses = await fetchConnectorStatuses();
|
||||
setConnectors((curr) => applyConnectorStatuses(curr, statuses));
|
||||
const reloadConnectorCatalog = useCallback(async (options: { refreshDiscovery?: boolean } = {}) => {
|
||||
setConnectors(await fetchConnectorCatalogSnapshot(options));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -294,7 +271,7 @@ export function EntryView({
|
||||
// open the Settings → Connectors surface.
|
||||
setConnectorsLoading(true);
|
||||
(async () => {
|
||||
const next = await fetchConnectors();
|
||||
const next = await fetchConnectorCatalogSnapshot();
|
||||
if (cancelled) return;
|
||||
setConnectors(next);
|
||||
setConnectorsLoading(false);
|
||||
@@ -309,11 +286,18 @@ export function EntryView({
|
||||
const data = event.data;
|
||||
if (!data || typeof data !== 'object' || (data as { type?: unknown }).type !== CONNECTOR_CALLBACK_MESSAGE_TYPE) return;
|
||||
if (!isTrustedConnectorCallbackOrigin(event.origin)) return;
|
||||
void reloadConnectorStatuses();
|
||||
void reloadConnectorCatalog({ refreshDiscovery: true });
|
||||
}
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [reloadConnectorStatuses]);
|
||||
}, [reloadConnectorCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
function onConnectorsChanged() {
|
||||
void reloadConnectorCatalog({ refreshDiscovery: true });
|
||||
}
|
||||
return listenForConnectorsChanged(onConnectorsChanged);
|
||||
}, [reloadConnectorCatalog]);
|
||||
|
||||
// When the OAuth flow is handed off to the user's system browser (desktop
|
||||
// shell opens connector auth URLs externally rather than in an Electron
|
||||
@@ -321,12 +305,22 @@ export function EntryView({
|
||||
// Refresh connector statuses whenever the window regains focus so the UI
|
||||
// picks up a just-completed connection without manual intervention.
|
||||
useEffect(() => {
|
||||
function onFocus() {
|
||||
void reloadConnectorStatuses();
|
||||
function refreshAfterReturn() {
|
||||
void reloadConnectorCatalog({ refreshDiscovery: true });
|
||||
}
|
||||
window.addEventListener('focus', onFocus);
|
||||
return () => window.removeEventListener('focus', onFocus);
|
||||
}, [reloadConnectorStatuses]);
|
||||
function onVisibilityChange() {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
refreshAfterReturn();
|
||||
}
|
||||
window.addEventListener('focus', refreshAfterReturn);
|
||||
window.addEventListener('pageshow', refreshAfterReturn);
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
return () => {
|
||||
window.removeEventListener('focus', refreshAfterReturn);
|
||||
window.removeEventListener('pageshow', refreshAfterReturn);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
};
|
||||
}, [reloadConnectorCatalog]);
|
||||
|
||||
return (
|
||||
<EntryShell
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
connectConnector,
|
||||
fetchConnectorStatuses,
|
||||
} from '../providers/registry';
|
||||
import { notifyConnectorsChanged } from './connectors-events';
|
||||
import { hasConnectorStatusChanges } from './connectors-state';
|
||||
|
||||
const TYPES: MemoryType[] = ['user', 'feedback', 'project', 'reference'];
|
||||
|
||||
@@ -751,6 +753,7 @@ export function MemorySection({
|
||||
readPendingConnectorAuthIds,
|
||||
);
|
||||
const [connectorConnectErrors, setConnectorConnectErrors] = useState<Record<string, string>>({});
|
||||
const connectorsRef = useRef(connectors);
|
||||
|
||||
const fireFlash = useCallback((kind: FlashKind) => {
|
||||
setFlash({ kind, key: Date.now() });
|
||||
@@ -762,6 +765,10 @@ export function MemorySection({
|
||||
return () => clearTimeout(id);
|
||||
}, [flash]);
|
||||
|
||||
useEffect(() => {
|
||||
connectorsRef.current = connectors;
|
||||
}, [connectors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTarget) return;
|
||||
editorRef.current?.scrollIntoView?.({ block: 'start', behavior: 'smooth' });
|
||||
@@ -1079,6 +1086,7 @@ export function MemorySection({
|
||||
|
||||
const refreshMemoryConnectorStatuses = useCallback(async () => {
|
||||
const statuses = await fetchConnectorStatuses();
|
||||
const statusChanged = hasConnectorStatusChanges(connectorsRef.current, statuses);
|
||||
setConnectorStatuses(statuses);
|
||||
setConnectors((prev) => applyMemoryConnectorStatuses(prev, statuses));
|
||||
setPendingConnectorAuthIds((prev) => {
|
||||
@@ -1099,6 +1107,7 @@ export function MemorySection({
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
if (statusChanged) notifyConnectorsChanged();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1139,6 +1148,7 @@ export function MemorySection({
|
||||
});
|
||||
try {
|
||||
const result = await connectConnector(connectorId);
|
||||
if (result.connector?.status === 'connected') notifyConnectorsChanged();
|
||||
const requiresAuthorizationCompletion =
|
||||
result.auth?.kind === 'redirect_required' || result.auth?.kind === 'pending';
|
||||
setConnectors((prev) =>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export const CONNECTOR_CALLBACK_MESSAGE_TYPE = 'open-design:connector-connected';
|
||||
export const CONNECTORS_CHANGED_EVENT = 'open-design:connectors-changed';
|
||||
|
||||
export function notifyConnectorsChanged(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent(CONNECTORS_CHANGED_EVENT));
|
||||
} catch {
|
||||
window.dispatchEvent(new Event(CONNECTORS_CHANGED_EVENT));
|
||||
}
|
||||
}
|
||||
|
||||
export function listenForConnectorsChanged(listener: () => void): () => void {
|
||||
if (typeof window === 'undefined') return () => {};
|
||||
let scheduled = false;
|
||||
let active = true;
|
||||
const scheduleListener = () => {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
queueMicrotask(() => {
|
||||
scheduled = false;
|
||||
if (!active) return;
|
||||
listener();
|
||||
});
|
||||
};
|
||||
window.addEventListener(CONNECTORS_CHANGED_EVENT, scheduleListener);
|
||||
return () => {
|
||||
active = false;
|
||||
window.removeEventListener(CONNECTORS_CHANGED_EVENT, scheduleListener);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ConnectorDetail, ConnectorStatusResponse } from '@open-design/contracts';
|
||||
import {
|
||||
fetchConnectorDiscovery,
|
||||
fetchConnectors,
|
||||
fetchConnectorStatuses,
|
||||
} from '../providers/registry';
|
||||
|
||||
export type ConnectorAuthSnapshot = Pick<ConnectorDetail, 'status'> &
|
||||
Partial<Pick<ConnectorDetail, 'accountLabel' | 'lastError'>>;
|
||||
|
||||
export function connectorAuthSnapshotChanged(
|
||||
current: ConnectorAuthSnapshot | null | undefined,
|
||||
next: ConnectorAuthSnapshot | null | undefined,
|
||||
): boolean {
|
||||
if (current == null && next == null) return false;
|
||||
if (current == null || next == null) return true;
|
||||
return (
|
||||
next.status !== current.status ||
|
||||
next.accountLabel !== current.accountLabel ||
|
||||
next.lastError !== current.lastError
|
||||
);
|
||||
}
|
||||
|
||||
export function hasConnectorStatusChanges(
|
||||
current: ConnectorDetail[],
|
||||
statuses: ConnectorStatusResponse['statuses'],
|
||||
): boolean {
|
||||
return current.some((connector) => connectorAuthSnapshotChanged(connector, statuses[connector.id]));
|
||||
}
|
||||
|
||||
export function mergeConnectorCatalog(
|
||||
current: ConnectorDetail[],
|
||||
incoming: ConnectorDetail[],
|
||||
): ConnectorDetail[] {
|
||||
if (current.length === 0) return incoming;
|
||||
const incomingById = new Map(incoming.map((connector) => [connector.id, connector]));
|
||||
const merged = current.map((connector) => {
|
||||
const next = incomingById.get(connector.id);
|
||||
return next ? { ...connector, ...next } : connector;
|
||||
});
|
||||
const currentIds = new Set(current.map((connector) => connector.id));
|
||||
for (const connector of incoming) {
|
||||
if (!currentIds.has(connector.id)) merged.push(connector);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function applyConnectorStatuses(
|
||||
current: ConnectorDetail[],
|
||||
statuses: ConnectorStatusResponse['statuses'],
|
||||
): ConnectorDetail[] {
|
||||
if (!Object.keys(statuses).length) return current;
|
||||
return current.map((connector) => {
|
||||
const next = statuses[connector.id];
|
||||
if (!next) return connector;
|
||||
const { accountLabel: _accountLabel, lastError: _lastError, ...base } = connector;
|
||||
return {
|
||||
...base,
|
||||
status: next.status,
|
||||
...(next.accountLabel === undefined ? {} : { accountLabel: next.accountLabel }),
|
||||
...(next.lastError === undefined ? {} : { lastError: next.lastError }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchConnectorCatalogSnapshot(options: {
|
||||
refreshDiscovery?: boolean;
|
||||
} = {}): Promise<ConnectorDetail[]> {
|
||||
const [catalog, discovery, statuses] = await Promise.all([
|
||||
fetchConnectors(),
|
||||
options.refreshDiscovery ? fetchConnectorDiscovery({ refresh: true }) : Promise.resolve([]),
|
||||
fetchConnectorStatuses(),
|
||||
]);
|
||||
return applyConnectorStatuses(mergeConnectorCatalog(catalog, discovery), statuses);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { ComponentProps } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatComposer } from '../../src/components/ChatComposer';
|
||||
import { CONNECTORS_CHANGED_EVENT } from '../../src/components/connectors-events';
|
||||
import { composerText, flushMounts } from '../helpers/lexical-composer';
|
||||
|
||||
const DESIGN_TASTE_SKILL = {
|
||||
@@ -97,6 +98,17 @@ const FIGMA_CONNECTOR = {
|
||||
toolCount: 1,
|
||||
};
|
||||
|
||||
const GMAIL_CONNECTOR = {
|
||||
...FIGMA_CONNECTOR,
|
||||
id: 'gmail',
|
||||
name: 'Gmail',
|
||||
category: 'email',
|
||||
description: 'Reads Gmail messages.',
|
||||
accountLabel: 'Inbox',
|
||||
allowedToolNames: ['GMAIL_FETCH_EMAILS'],
|
||||
curatedToolNames: ['GMAIL_FETCH_EMAILS'],
|
||||
};
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
function renderComposer(
|
||||
@@ -173,6 +185,18 @@ beforeEach(() => {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/connectors/status') {
|
||||
return new Response(JSON.stringify({ statuses: { figma: { status: 'connected' } } }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/connectors/discovery?refresh=true') {
|
||||
return new Response(JSON.stringify({ connectors: [FIGMA_CONNECTOR, GMAIL_CONNECTOR] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
@@ -250,4 +274,24 @@ describe('ChatComposer design toolbox', () => {
|
||||
expect(composerText()).toContain('Do not only use design toolbox recommendations');
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes connected connectors when connector auth changes in another surface', async () => {
|
||||
renderComposer();
|
||||
await flushMounts();
|
||||
|
||||
fireEvent.click(screen.getByTestId('chat-plus-trigger'));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: /Design toolbox/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Figma')).toBeTruthy();
|
||||
});
|
||||
|
||||
window.dispatchEvent(new Event(CONNECTORS_CHANGED_EVENT));
|
||||
|
||||
const search = screen.getByLabelText('Search design toolbox resources');
|
||||
fireEvent.change(search, { target: { value: 'gmail' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Gmail')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,12 +8,14 @@ import { ConnectorsBrowser } from '../../src/components/ConnectorsBrowser';
|
||||
import {
|
||||
cancelConnectorAuthorization,
|
||||
connectConnector,
|
||||
disconnectConnector,
|
||||
fetchConnectorDetail,
|
||||
fetchConnectorDiscovery,
|
||||
fetchConnectors,
|
||||
fetchConnectorStatuses,
|
||||
openExternalUrl,
|
||||
} from '../../src/providers/registry';
|
||||
import { CONNECTORS_CHANGED_EVENT } from '../../src/components/connectors-events';
|
||||
|
||||
vi.mock('../../src/providers/registry', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
|
||||
@@ -64,6 +66,7 @@ describe('ConnectorsBrowser', () => {
|
||||
cleanup();
|
||||
vi.mocked(cancelConnectorAuthorization).mockReset();
|
||||
vi.mocked(connectConnector).mockReset();
|
||||
vi.mocked(disconnectConnector).mockReset();
|
||||
vi.mocked(fetchConnectors).mockReset();
|
||||
vi.mocked(fetchConnectorDetail).mockReset();
|
||||
vi.mocked(fetchConnectorDiscovery).mockReset();
|
||||
@@ -71,6 +74,7 @@ describe('ConnectorsBrowser', () => {
|
||||
vi.mocked(openExternalUrl).mockReset();
|
||||
vi.mocked(cancelConnectorAuthorization).mockResolvedValue(null);
|
||||
vi.mocked(connectConnector).mockResolvedValue({ connector: null });
|
||||
vi.mocked(disconnectConnector).mockResolvedValue(null);
|
||||
vi.mocked(fetchConnectorDetail).mockResolvedValue(null);
|
||||
vi.mocked(openExternalUrl).mockResolvedValue(true);
|
||||
window.sessionStorage.clear();
|
||||
@@ -87,6 +91,79 @@ describe('ConnectorsBrowser', () => {
|
||||
expect(screen.getByTestId('connector-grid-wrap').className).toContain('is-masked');
|
||||
});
|
||||
|
||||
it('broadcasts connector changes when a connect action completes immediately', async () => {
|
||||
const availableConnector: ConnectorDetail = {
|
||||
...configuredComposioConnector,
|
||||
status: 'available',
|
||||
auth: { provider: 'composio', configured: true },
|
||||
};
|
||||
const connectedConnector: ConnectorDetail = {
|
||||
...availableConnector,
|
||||
status: 'connected',
|
||||
accountLabel: 'inbox@example.com',
|
||||
};
|
||||
const onChanged = vi.fn();
|
||||
window.addEventListener(CONNECTORS_CHANGED_EVENT, onChanged);
|
||||
vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]);
|
||||
vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]);
|
||||
vi.mocked(fetchConnectorStatuses).mockResolvedValue({});
|
||||
vi.mocked(connectConnector).mockResolvedValue({
|
||||
connector: connectedConnector,
|
||||
auth: { kind: 'connected' },
|
||||
});
|
||||
|
||||
render(<ConnectorsBrowser composioConfigured />);
|
||||
|
||||
await screen.findByText('GitHub');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }));
|
||||
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
|
||||
window.removeEventListener(CONNECTORS_CHANGED_EVENT, onChanged);
|
||||
});
|
||||
|
||||
it('broadcasts connector changes when a status refresh observes a completed authorization', async () => {
|
||||
const availableConnector: ConnectorDetail = {
|
||||
...configuredComposioConnector,
|
||||
status: 'available',
|
||||
auth: { provider: 'composio', configured: true },
|
||||
};
|
||||
const onChanged = vi.fn();
|
||||
window.addEventListener(CONNECTORS_CHANGED_EVENT, onChanged);
|
||||
vi.mocked(fetchConnectors).mockResolvedValue([availableConnector]);
|
||||
vi.mocked(fetchConnectorDiscovery).mockResolvedValue([availableConnector]);
|
||||
vi.mocked(fetchConnectorStatuses).mockResolvedValue({
|
||||
github: { status: 'connected', accountLabel: 'inbox@example.com' },
|
||||
});
|
||||
|
||||
render(<ConnectorsBrowser composioConfigured />);
|
||||
|
||||
await screen.findByText('GitHub');
|
||||
fireEvent(window, new Event('focus'));
|
||||
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
|
||||
window.removeEventListener(CONNECTORS_CHANGED_EVENT, onChanged);
|
||||
});
|
||||
|
||||
it('broadcasts connector changes after disconnect succeeds', async () => {
|
||||
const onChanged = vi.fn();
|
||||
window.addEventListener(CONNECTORS_CHANGED_EVENT, onChanged);
|
||||
vi.mocked(fetchConnectors).mockResolvedValue([configuredComposioConnector]);
|
||||
vi.mocked(fetchConnectorDiscovery).mockResolvedValue([configuredComposioConnector]);
|
||||
vi.mocked(fetchConnectorStatuses).mockResolvedValue({});
|
||||
vi.mocked(disconnectConnector).mockResolvedValue({
|
||||
...configuredComposioConnector,
|
||||
status: 'available',
|
||||
});
|
||||
|
||||
render(<ConnectorsBrowser composioConfigured />);
|
||||
|
||||
await screen.findByText('GitHub');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }));
|
||||
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
|
||||
window.removeEventListener(CONNECTORS_CHANGED_EVENT, onChanged);
|
||||
});
|
||||
|
||||
it('keeps discovered tools when discovery resolves before the base catalog', async () => {
|
||||
const base = deferred<ConnectorDetail[]>();
|
||||
const discovery = deferred<ConnectorDetail[]>();
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DesignSystemCreationFlow,
|
||||
DesignSystemDetailView,
|
||||
} from '../../src/components/DesignSystemFlow';
|
||||
import { CONNECTORS_CHANGED_EVENT } from '../../src/components/connectors-events';
|
||||
import type { AppConfig, Conversation, DesignSystemDetail, Project, ProjectFile } from '../../src/types';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
|
||||
@@ -1617,33 +1618,40 @@ describe('DesignSystemCreationFlow', () => {
|
||||
const config = {
|
||||
composio: { apiKeyConfigured: true, apiKeyTail: 'uQEg' },
|
||||
} as AppConfig;
|
||||
const onConnectorsChanged = vi.fn();
|
||||
window.addEventListener(CONNECTORS_CHANGED_EVENT, onConnectorsChanged);
|
||||
|
||||
render(
|
||||
<DesignSystemCreationFlow
|
||||
onBack={() => {}}
|
||||
onCreated={() => {}}
|
||||
config={config}
|
||||
/>,
|
||||
);
|
||||
try {
|
||||
render(
|
||||
<DesignSystemCreationFlow
|
||||
onBack={() => {}}
|
||||
onCreated={() => {}}
|
||||
config={config}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show access methods' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Connect via Composio' })).toBeTruthy());
|
||||
expect((screen.getByPlaceholderText('https://github.com/owner/repo') as HTMLInputElement).disabled).toBe(false);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show access methods' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Connect via Composio' })).toBeTruthy());
|
||||
expect((screen.getByPlaceholderText('https://github.com/owner/repo') as HTMLInputElement).disabled).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect via Composio' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect via Composio' }));
|
||||
|
||||
await waitFor(() => expect(mocks.connectConnector).toHaveBeenCalledWith('github'));
|
||||
await waitFor(() => expect(screen.getByText(/Connected as qiongyu1999/i)).toBeTruthy());
|
||||
expect(screen.queryByRole('button', { name: 'Configure' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Disconnect' })).toBeTruthy();
|
||||
const input = screen.getByPlaceholderText('https://github.com/owner/repo') as HTMLInputElement;
|
||||
expect(input.disabled).toBe(false);
|
||||
await waitFor(() => expect(mocks.connectConnector).toHaveBeenCalledWith('github'));
|
||||
await waitFor(() => expect(screen.getByText(/Connected as qiongyu1999/i)).toBeTruthy());
|
||||
await waitFor(() => expect(onConnectorsChanged).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByRole('button', { name: 'Configure' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Disconnect' })).toBeTruthy();
|
||||
const input = screen.getByPlaceholderText('https://github.com/owner/repo') as HTMLInputElement;
|
||||
expect(input.disabled).toBe(false);
|
||||
|
||||
fireEvent.change(input, { target: { value: 'https://github.com/nexu-io/open-design/' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
||||
fireEvent.change(input, { target: { value: 'https://github.com/nexu-io/open-design/' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }));
|
||||
|
||||
expect(screen.getByText('nexu-io/open-design')).toBeTruthy();
|
||||
expect(input.value).toBe('');
|
||||
expect(screen.getByText('nexu-io/open-design')).toBeTruthy();
|
||||
expect(input.value).toBe('');
|
||||
} finally {
|
||||
window.removeEventListener(CONNECTORS_CHANGED_EVENT, onConnectorsChanged);
|
||||
}
|
||||
});
|
||||
|
||||
it('hides Composio connection ids in the connected GitHub label', async () => {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { ConnectorDetail } from '@open-design/contracts';
|
||||
|
||||
import { CONNECTORS_CHANGED_EVENT } from '../../src/components/connectors-events';
|
||||
import { EntryView } from '../../src/components/EntryView';
|
||||
import {
|
||||
fetchConnectorDiscovery,
|
||||
fetchConnectors,
|
||||
fetchConnectorStatuses,
|
||||
} from '../../src/providers/registry';
|
||||
|
||||
vi.mock('../../src/components/EntryShell', () => ({
|
||||
EntryShell: ({ connectors }: { connectors: ConnectorDetail[] }) => (
|
||||
<div data-testid="entry-connectors">
|
||||
{connectors.map((connector) => (
|
||||
<span key={connector.id}>{connector.name}:{connector.status}</span>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/providers/registry', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
|
||||
'../../src/providers/registry',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
fetchConnectorDiscovery: vi.fn(),
|
||||
fetchConnectors: vi.fn(),
|
||||
fetchConnectorStatuses: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const githubConnector: ConnectorDetail = {
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
provider: 'Composio',
|
||||
category: 'Developer',
|
||||
status: 'available',
|
||||
auth: { provider: 'composio', configured: true },
|
||||
tools: [],
|
||||
};
|
||||
|
||||
const gmailConnector: ConnectorDetail = {
|
||||
id: 'gmail',
|
||||
name: 'Gmail',
|
||||
provider: 'Composio',
|
||||
category: 'Email',
|
||||
status: 'available',
|
||||
auth: { provider: 'composio', configured: true },
|
||||
tools: [],
|
||||
};
|
||||
|
||||
function renderEntryView(overrides: Partial<ComponentProps<typeof EntryView>> = {}) {
|
||||
return render(
|
||||
<EntryView
|
||||
skills={[]}
|
||||
designTemplates={[]}
|
||||
designSystems={[]}
|
||||
projects={[]}
|
||||
templates={[]}
|
||||
onDeleteTemplate={vi.fn()}
|
||||
promptTemplates={[]}
|
||||
defaultDesignSystemId={null}
|
||||
agents={[]}
|
||||
config={{
|
||||
mode: 'daemon',
|
||||
apiKey: '',
|
||||
apiProtocol: 'openai',
|
||||
apiVersion: '',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
agentId: 'codex',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
onboardingCompleted: true,
|
||||
composio: { apiKey: '', apiKeyConfigured: true, apiKeyTail: '1234' },
|
||||
mediaProviders: {},
|
||||
agentModels: {},
|
||||
agentCliEnv: {},
|
||||
}}
|
||||
daemonLive
|
||||
onModeChange={vi.fn()}
|
||||
onAgentChange={vi.fn()}
|
||||
onAgentModelChange={vi.fn()}
|
||||
onApiProtocolChange={vi.fn()}
|
||||
onApiModelChange={vi.fn()}
|
||||
onConfigPersist={vi.fn()}
|
||||
onRefreshAgents={vi.fn()}
|
||||
onThemeChange={vi.fn()}
|
||||
onCreateProject={vi.fn()}
|
||||
onCreatePluginShareProject={vi.fn()}
|
||||
onImportClaudeDesign={vi.fn()}
|
||||
onOpenProject={vi.fn()}
|
||||
onOpenLiveArtifact={vi.fn()}
|
||||
onDeleteProject={vi.fn()}
|
||||
onRenameProject={vi.fn()}
|
||||
onChangeDefaultDesignSystem={vi.fn()}
|
||||
onPersistComposioKey={vi.fn()}
|
||||
onOpenSettings={vi.fn()}
|
||||
onCompleteOnboarding={vi.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.mocked(fetchConnectors).mockReset();
|
||||
vi.mocked(fetchConnectorDiscovery).mockReset();
|
||||
vi.mocked(fetchConnectorStatuses).mockReset();
|
||||
});
|
||||
|
||||
describe('EntryView connector refresh', () => {
|
||||
it('inserts newly discovered connected connectors after a connector change event', async () => {
|
||||
vi.mocked(fetchConnectors)
|
||||
.mockResolvedValueOnce([githubConnector])
|
||||
.mockResolvedValueOnce([githubConnector]);
|
||||
vi.mocked(fetchConnectorDiscovery)
|
||||
.mockResolvedValueOnce([gmailConnector]);
|
||||
vi.mocked(fetchConnectorStatuses)
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ gmail: { status: 'connected', accountLabel: 'inbox@example.com' } });
|
||||
|
||||
renderEntryView();
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('entry-connectors').textContent).toContain('GitHub:available'));
|
||||
expect(screen.getByTestId('entry-connectors').textContent).not.toContain('Gmail');
|
||||
|
||||
window.dispatchEvent(new Event(CONNECTORS_CHANGED_EVENT));
|
||||
window.dispatchEvent(new Event(CONNECTORS_CHANGED_EVENT));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('entry-connectors').textContent).toContain('Gmail:connected');
|
||||
});
|
||||
expect(fetchConnectorDiscovery).toHaveBeenCalledWith({ refresh: true });
|
||||
expect(fetchConnectorDiscovery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type { ComponentProps } from 'react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MemorySection } from '../../src/components/MemorySection';
|
||||
import { CONNECTORS_CHANGED_EVENT } from '../../src/components/connectors-events';
|
||||
import { I18nProvider } from '../../src/i18n';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -1012,6 +1013,117 @@ describe('MemorySection', () => {
|
||||
expect((within(remountedGithubRow).getByRole('button', { name: 'Connect GitHub' }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('broadcasts connector changes when pending connector authorization completes', async () => {
|
||||
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
||||
let connected = false;
|
||||
const authWindow = {
|
||||
document: {
|
||||
title: '',
|
||||
body: { innerHTML: '' },
|
||||
},
|
||||
location: { replace: vi.fn() },
|
||||
close: vi.fn(),
|
||||
};
|
||||
vi.spyOn(window, 'open').mockReturnValue(authWindow as unknown as Window);
|
||||
const onConnectorsChanged = vi.fn();
|
||||
window.addEventListener(CONNECTORS_CHANGED_EVENT, onConnectorsChanged);
|
||||
|
||||
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
if (url === '/api/memory' && (!init || init.method === undefined)) {
|
||||
return new Response(JSON.stringify({
|
||||
enabled: true,
|
||||
rootDir: '/tmp/memory',
|
||||
index: '# Memory\n',
|
||||
entries: [],
|
||||
extraction: null,
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/memory/extractions') {
|
||||
return new Response(JSON.stringify({ extractions: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
if (url === '/api/connectors/discovery?hydrateTools=false') {
|
||||
return new Response(JSON.stringify({
|
||||
connectors: [
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
provider: 'composio',
|
||||
category: 'Developer',
|
||||
status: 'available',
|
||||
tools: [],
|
||||
},
|
||||
],
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/connectors/status') {
|
||||
return new Response(JSON.stringify({
|
||||
statuses: {
|
||||
github: connected
|
||||
? { status: 'connected', accountLabel: 'octo@example.test' }
|
||||
: { status: 'available' },
|
||||
},
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/connectors/auth-configs/prepare' && init?.method === 'POST') {
|
||||
return new Response(JSON.stringify({
|
||||
results: {
|
||||
github: { status: 'ready', authConfigId: 'ac_github' },
|
||||
},
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (url === '/api/connectors/github/connect' && init?.method === 'POST') {
|
||||
return new Response(JSON.stringify({
|
||||
connector: {
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
provider: 'composio',
|
||||
category: 'Developer',
|
||||
status: 'available',
|
||||
tools: [],
|
||||
},
|
||||
auth: {
|
||||
kind: 'redirect_required',
|
||||
redirectUrl: 'https://example.com/github-oauth',
|
||||
},
|
||||
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify({}), { status: 404 });
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
renderMemorySection();
|
||||
fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' }));
|
||||
const githubRow = await waitFor(() => {
|
||||
const row = document.querySelector('[data-memory-connector-id="github"]');
|
||||
expect(row).toBeTruthy();
|
||||
return row as HTMLElement;
|
||||
});
|
||||
|
||||
fireEvent.click(within(githubRow).getByRole('button', { name: 'Connect GitHub' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authWindow.location.replace).toHaveBeenCalledWith('https://example.com/github-oauth');
|
||||
});
|
||||
expect(onConnectorsChanged).not.toHaveBeenCalled();
|
||||
|
||||
connected = true;
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
origin: window.location.origin,
|
||||
data: { type: 'open-design:connector-connected' },
|
||||
}));
|
||||
|
||||
await waitFor(() => expect(onConnectorsChanged).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(within(githubRow).getByText('octo@example.test')).toBeTruthy());
|
||||
expect(within(githubRow).getByText('Select')).toBeTruthy();
|
||||
} finally {
|
||||
window.removeEventListener(CONNECTORS_CHANGED_EVENT, onConnectorsChanged);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows reconnect guidance for memory connectors with stale authorization', async () => {
|
||||
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
|
||||
const lastError = 'GitHub authorization expired. Reconnect GitHub.';
|
||||
|
||||
+2
-2
@@ -9,6 +9,6 @@
|
||||
# 1. Temporarily set the consuming `hash = lib.fakeHash;`
|
||||
# 2. Run the relevant nix build/flake check
|
||||
# 3. Copy the expected hash printed by Nix into the matching field below
|
||||
daemonHash = "sha256-kOnL4Bhcs6QWPInO9hyoSbBjgfwu6tskLecUDCkMTe4=";
|
||||
webHash = "sha256-rP2UOBuqwIGJpABSdAWJd+hlvdjFNdCkKE7CzesnojQ=";
|
||||
daemonHash = "sha256-AEg1yKQK55U9P5EJPNqWNkF9teKAV0rwV84F8Im2Ir0=";
|
||||
webHash = "sha256-/VNR08beUFynS6/uZHoA9AlZE8PPPicGAyJJ6Oy7trg=";
|
||||
}
|
||||
|
||||
Generated
+25
-25
@@ -678,8 +678,8 @@ importers:
|
||||
version: 4.1.6(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(jsdom@29.1.1)(vite@7.3.3(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.9.0))
|
||||
optionalDependencies:
|
||||
'@powerformer/vela-cli':
|
||||
specifier: 0.0.11
|
||||
version: 0.0.11
|
||||
specifier: 0.0.12
|
||||
version: 0.0.12
|
||||
|
||||
tools/serve:
|
||||
dependencies:
|
||||
@@ -1817,33 +1817,33 @@ packages:
|
||||
'@posthog/types@1.374.2':
|
||||
resolution: {integrity: sha512-ZghQSFMi+HFJNPvPjBoyY/jWQ+q6mSQVtWQxOHMSbBidUZjsyYbxYxBFbHy2qWLNe4mEpX+Wqir2Q4I/4AVvJQ==}
|
||||
|
||||
'@powerformer/vela-cli-darwin-arm64@0.0.11':
|
||||
resolution: {integrity: sha512-F4QinKhfQZi6hcsXfJz3tq6X5VzKIrDnStOCCWl6h8VLO5AUXN7GIPbIPC337MoJ0rOiaHU9awGk5yI9ivx5oA==}
|
||||
'@powerformer/vela-cli-darwin-arm64@0.0.12':
|
||||
resolution: {integrity: sha512-v3xB3FKOuvXlU/6KArZlaSqc9QOeBZBzKtBdBnvouzLro+0Z6gJ91zZZjsge6+7lGTJYPFNLzZ7jSmgpkx8QxA==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@powerformer/vela-cli-darwin-x64@0.0.11':
|
||||
resolution: {integrity: sha512-Wkpv9MroS/PIhLfgtk4cIJv4jRULx+q7encGm2kG09d9Y6mfNmeYUIKbt7lf58ArlGbq9Ue9N2WnXICwv16dlg==}
|
||||
'@powerformer/vela-cli-darwin-x64@0.0.12':
|
||||
resolution: {integrity: sha512-WhAJf5z3bPxdSvfEWMevaIDNjB1ALzoQaM5nw0cn17X9iMMpEENwBu8xvyIBH1VGinPOdUwXxI2Clpf/cc92KQ==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@powerformer/vela-cli-linux-arm64@0.0.11':
|
||||
resolution: {integrity: sha512-Cl/YcFObTOfKw8EViEHQYzpIGq7WO3zpxeHJA493AwDXSsj40rjRMB7mNCofsvslXxD0NVhTSFqE7IFzvpuAzg==}
|
||||
'@powerformer/vela-cli-linux-arm64@0.0.12':
|
||||
resolution: {integrity: sha512-fuS0u7Ku5ohTdULt9O7Y5FQ9mLdFPd89j5cFB+/ANveSqG7dqYbZpfjtcR4SDnbPTZ94TWAx6sGjVPssW5BO2A==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@powerformer/vela-cli-linux-x64@0.0.11':
|
||||
resolution: {integrity: sha512-xanh1LmPpscCdfiV95XQNUJx2/nZWcn+bzrXBJgi1hfPhN6vSClP5J4YrNCLTKrpBH3J6TdIfWNe5Zqa8V0XeQ==}
|
||||
'@powerformer/vela-cli-linux-x64@0.0.12':
|
||||
resolution: {integrity: sha512-9hMmJ3FGoMdYkFly9v91l/O9jz3dpv24g3PTOr09382bl3Atz1oAOmL6RGWl/JmPDCdUd1CkcOrp/9PyTW9Rcw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@powerformer/vela-cli-win32-x64@0.0.11':
|
||||
resolution: {integrity: sha512-Zp4Swj9t1N5RnoLxl/NVULa2vz8jFJSEZxrXUtC4jGlG6ArqobNPeVOZvIKgDOzbfvH4zvWUc22cAfiZ5zbEjw==}
|
||||
'@powerformer/vela-cli-win32-x64@0.0.12':
|
||||
resolution: {integrity: sha512-wHIfOTWQzTEGS/pZ+S42ZjkjpVDpIJrTLdDGHuqqKjyRPbJqb/d1cWcDs6fLlHHkiXYxm+i76980di05dcLcJQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@powerformer/vela-cli@0.0.11':
|
||||
resolution: {integrity: sha512-yS+eC/pMZNjg2/2t4Uh6JefhP+KTU+pvQRJm8wtqUOENlumV3wuaRO87UmGBcPM/T/wyepc1c4Mr3miWDnL+sg==}
|
||||
'@powerformer/vela-cli@0.0.12':
|
||||
resolution: {integrity: sha512-3IQAv4TElGXo1PNKtCbwWCoKU0Wjk/njm0Wy7IickOP45oYAkQDduW4t+V7WIEtGWwcY7zZqlwDYpiVTUrClkw==}
|
||||
hasBin: true
|
||||
|
||||
'@preact/signals-core@1.14.2':
|
||||
@@ -6532,28 +6532,28 @@ snapshots:
|
||||
|
||||
'@posthog/types@1.374.2': {}
|
||||
|
||||
'@powerformer/vela-cli-darwin-arm64@0.0.11':
|
||||
'@powerformer/vela-cli-darwin-arm64@0.0.12':
|
||||
optional: true
|
||||
|
||||
'@powerformer/vela-cli-darwin-x64@0.0.11':
|
||||
'@powerformer/vela-cli-darwin-x64@0.0.12':
|
||||
optional: true
|
||||
|
||||
'@powerformer/vela-cli-linux-arm64@0.0.11':
|
||||
'@powerformer/vela-cli-linux-arm64@0.0.12':
|
||||
optional: true
|
||||
|
||||
'@powerformer/vela-cli-linux-x64@0.0.11':
|
||||
'@powerformer/vela-cli-linux-x64@0.0.12':
|
||||
optional: true
|
||||
|
||||
'@powerformer/vela-cli-win32-x64@0.0.11':
|
||||
'@powerformer/vela-cli-win32-x64@0.0.12':
|
||||
optional: true
|
||||
|
||||
'@powerformer/vela-cli@0.0.11':
|
||||
'@powerformer/vela-cli@0.0.12':
|
||||
optionalDependencies:
|
||||
'@powerformer/vela-cli-darwin-arm64': 0.0.11
|
||||
'@powerformer/vela-cli-darwin-x64': 0.0.11
|
||||
'@powerformer/vela-cli-linux-arm64': 0.0.11
|
||||
'@powerformer/vela-cli-linux-x64': 0.0.11
|
||||
'@powerformer/vela-cli-win32-x64': 0.0.11
|
||||
'@powerformer/vela-cli-darwin-arm64': 0.0.12
|
||||
'@powerformer/vela-cli-darwin-x64': 0.0.12
|
||||
'@powerformer/vela-cli-linux-arm64': 0.0.12
|
||||
'@powerformer/vela-cli-linux-x64': 0.0.12
|
||||
'@powerformer/vela-cli-win32-x64': 0.0.12
|
||||
optional: true
|
||||
|
||||
'@preact/signals-core@1.14.2': {}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"resedit": "1.7.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@powerformer/vela-cli": "0.0.11"
|
||||
"@powerformer/vela-cli": "0.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.12.2",
|
||||
|
||||
Reference in New Issue
Block a user