[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:
Caprika
2026-06-05 10:31:46 +00:00
committed by GitHub
co-authored by open-design-bot[bot] <282769551+open-design-bot[bot]@users.noreply.github.com>
parent 57117c23c6
commit 02a1a5a537
15 changed files with 649 additions and 94 deletions
+19 -2
View File
@@ -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
+27 -4
View File
@@ -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',
+22 -1
View File
@@ -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) {
+32 -38
View File
@@ -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
+10
View File
@@ -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
View File
@@ -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=";
}
+25 -25
View File
@@ -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': {}
+1 -1
View File
@@ -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",