mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 20:16:48 +00:00
Reconnect in place from the chat bar and the health toast
- The Connect card's payload carries connection_id for the caller's own connection (never an owner's), so "Reconnect" in chat signs in again right there instead of sending the user to Settings. The bar offers Continue as soon as the connection works again, also after leaving and coming back to the chat. - The reconnect event carries source and tool counts, and the toast says what stopped: "3 sources are paused", "so agents can use it again", or both. Reconnect signs in again in place and the toast closes itself once the connection works; MCP servers still reconnect from their drawer.
This commit is contained in:
1 parent
a23ace3491
commit
f6c89503d8
16 files changed
+353
-30
No files matched your search
@@ -812,15 +812,24 @@ class ToolExecutor:
|
||||
|
||||
@staticmethod
|
||||
def _connection_payload(resolved) -> Dict:
|
||||
"""What the chat's Connect card needs; never an account or a secret."""
|
||||
return {
|
||||
"""What the chat's Connect card needs; never an account or a secret.
|
||||
|
||||
``connection_id`` is only the caller's own connection, which the card
|
||||
reconnects in place; an owner's account (``owner_account``) is not
|
||||
the caller's to reconnect.
|
||||
"""
|
||||
payload = {
|
||||
"connector_key": resolved.connector_key,
|
||||
"connector_name": resolved.connector_name,
|
||||
"status": (
|
||||
"missing" if resolved.row is None
|
||||
else (resolved.row.get("status") or "reconnect_needed")
|
||||
),
|
||||
"owner_account": bool(resolved.delegated),
|
||||
}
|
||||
if resolved.row is not None and not resolved.delegated:
|
||||
payload["connection_id"] = str(resolved.row["id"])
|
||||
return payload
|
||||
|
||||
def check_pause(self, tools_dict: Dict, call, llm_class_name: str) -> Optional[Dict]:
|
||||
"""Return a pending-action dict (approval / client / headless_denied) or None.
|
||||
|
||||
@@ -464,6 +464,7 @@ def mark_reconnect_needed(connection_id: str, error: str) -> None:
|
||||
if row is None:
|
||||
return
|
||||
already = normalize_status(row) == STATUS_RECONNECT
|
||||
counts = repo.resource_counts([connection_id]).get(str(connection_id), {})
|
||||
repo.update(connection_id, {"status": STATUS_RECONNECT, "last_error": error[:500]})
|
||||
conn.execute(
|
||||
text(
|
||||
@@ -480,6 +481,8 @@ def mark_reconnect_needed(connection_id: str, error: str) -> None:
|
||||
"connection_id": connection_id,
|
||||
"connector_key": catalog.connector_key_for_row(row),
|
||||
"name": serialize_connection(row)["name"],
|
||||
"source_count": counts.get("sources", 0),
|
||||
"tool_count": counts.get("tools", 0),
|
||||
},
|
||||
scope={"kind": "connection", "id": connection_id},
|
||||
)
|
||||
|
||||
@@ -20,6 +20,11 @@ vi.mock('../api/services/connectorsService', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const launch = vi.fn();
|
||||
vi.mock('../connectors/useConnectorLauncher', () => ({
|
||||
default: () => ({ launch, modals: null }),
|
||||
}));
|
||||
|
||||
import connectorsReducer from '../connectors/connectorsSlice';
|
||||
import ConnectToolCallBar from './ConnectToolCallBar';
|
||||
import type { ToolCallsType } from './types';
|
||||
@@ -54,13 +59,31 @@ describe('ConnectToolCallBar', () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (toolCall: ToolCallsType, onToolAction = vi.fn()) => {
|
||||
const render = async (
|
||||
toolCall: ToolCallsType,
|
||||
onToolAction = vi.fn(),
|
||||
connectors?: Record<string, unknown>,
|
||||
) => {
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
connectors: connectorsReducer,
|
||||
preference: (state = { token: null }) => state,
|
||||
},
|
||||
});
|
||||
preloadedState: connectors
|
||||
? {
|
||||
connectors: {
|
||||
enabled: true,
|
||||
loading: false,
|
||||
loaded: true,
|
||||
failed: false,
|
||||
catalog: [],
|
||||
connections: [],
|
||||
...connectors,
|
||||
},
|
||||
preference: { token: null },
|
||||
}
|
||||
: undefined,
|
||||
} as Parameters<typeof configureStore>[0]);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
@@ -103,6 +126,47 @@ describe('ConnectToolCallBar', () => {
|
||||
expect(onToolAction).toHaveBeenCalledWith('call-1', 'denied');
|
||||
});
|
||||
|
||||
const TELEGRAM = {
|
||||
key: 'telegram',
|
||||
name: 'Telegram',
|
||||
icon: 'tool_telegram',
|
||||
auth_kind: 'api_key',
|
||||
};
|
||||
const ownCall = (status: string): ToolCallsType => ({
|
||||
...call(status),
|
||||
connection_required: {
|
||||
connector_key: 'telegram',
|
||||
connector_name: 'Telegram',
|
||||
status,
|
||||
connection_id: 'conn-1',
|
||||
owner_account: false,
|
||||
},
|
||||
});
|
||||
|
||||
it('reconnects your own account right here', async () => {
|
||||
launch.mockClear();
|
||||
await render(ownCall('reconnect_needed'), vi.fn(), {
|
||||
catalog: [TELEGRAM],
|
||||
connections: [{ id: 'conn-1', status: 'reconnect_needed' }],
|
||||
});
|
||||
await act(async () =>
|
||||
button('conversation.toolApproval.connect:Telegram').click(),
|
||||
);
|
||||
expect(launch).toHaveBeenCalledWith(TELEGRAM, {
|
||||
mode: 'reconnect',
|
||||
connectionId: 'conn-1',
|
||||
});
|
||||
expect(document.body.textContent).not.toContain('DRAWER');
|
||||
});
|
||||
|
||||
it('offers Continue once the connection works again', async () => {
|
||||
await render(ownCall('reconnect_needed'), vi.fn(), {
|
||||
catalog: [TELEGRAM],
|
||||
connections: [{ id: 'conn-1', status: 'connected' }],
|
||||
});
|
||||
expect(button('conversation.toolApproval.continue')).toBeDefined();
|
||||
});
|
||||
|
||||
it('an account that needs signing in again is healed from its drawer', async () => {
|
||||
await render(call('reconnect_needed'));
|
||||
await act(async () =>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button } from '../components/ui/button';
|
||||
import ConnectorIcon from '../connectors/ConnectorIcon';
|
||||
import {
|
||||
loadConnectors,
|
||||
selectConnections,
|
||||
selectConnectorCatalog,
|
||||
selectConnectorsLoaded,
|
||||
} from '../connectors/connectorsSlice';
|
||||
@@ -34,12 +35,18 @@ export default function ConnectToolCallBar({
|
||||
const token = useSelector(selectToken);
|
||||
const catalog = useSelector(selectConnectorCatalog);
|
||||
const loaded = useSelector(selectConnectorsLoaded);
|
||||
const connections = useSelector(selectConnections);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const { launch, modals } = useConnectorLauncher({
|
||||
onConnected: () => setConnected(true),
|
||||
});
|
||||
const required = toolCall.connection_required;
|
||||
const connector = catalog.find((c) => c.key === required?.connector_key);
|
||||
const ownConnection = required?.connection_id
|
||||
? connections.find((c) => c.id === required.connection_id)
|
||||
: undefined;
|
||||
// Connected here, or healthy again when the user comes back to the chat.
|
||||
const ready = connected || ownConnection?.status === 'connected';
|
||||
const name =
|
||||
required?.connector_name ||
|
||||
connector?.name ||
|
||||
@@ -50,13 +57,22 @@ export default function ConnectToolCallBar({
|
||||
}, [loaded, dispatch, token]);
|
||||
|
||||
const connect = () => {
|
||||
// A member who has no account for the service connects one. An owner's
|
||||
// existing account that needs signing in again is healed from its drawer,
|
||||
// which reconnects that same connection for every tool and source.
|
||||
// No account yet: connect one. The caller's own account that needs
|
||||
// signing in again is reconnected right here (every tool and source on
|
||||
// it heals). MCP servers and anyone else's account go to the drawer.
|
||||
if (connector && required?.status === 'missing') {
|
||||
launch(connector);
|
||||
return;
|
||||
}
|
||||
const inPlace =
|
||||
connector?.auth_kind === 'oauth' || connector?.auth_kind === 'api_key';
|
||||
if (connector && required?.connection_id && inPlace) {
|
||||
launch(connector, {
|
||||
mode: 'reconnect',
|
||||
connectionId: required.connection_id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
navigate(
|
||||
`/settings/connectors${connector ? `?connector=${encodeURIComponent(connector.key)}` : ''}`,
|
||||
);
|
||||
@@ -78,7 +94,7 @@ export default function ConnectToolCallBar({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{connected ? (
|
||||
{ready ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
|
||||
@@ -28,6 +28,10 @@ export type ToolCallsType = {
|
||||
connector_key: string | null;
|
||||
connector_name: string | null;
|
||||
status: 'missing' | 'reconnect_needed' | 'disconnected' | 'error' | string;
|
||||
/** The caller's own connection, reconnected in place. */
|
||||
connection_id?: string;
|
||||
/** The tool runs on its owner's account, not the caller's. */
|
||||
owner_account?: boolean;
|
||||
};
|
||||
// Which connection a tool call used, for the connector's logo and name on
|
||||
// its chip (never an account or a secret).
|
||||
|
||||
@@ -1256,7 +1256,12 @@
|
||||
"failed": "Die Verbindung konnte nicht entfernt werden. Versuche es erneut."
|
||||
},
|
||||
"health": {
|
||||
"reconnect": "Verbinde {{name}} neu, um weiter zu synchronisieren."
|
||||
"reconnect": "Verbinde {{name}} neu, um weiter zu synchronisieren.",
|
||||
"reconnectSources_one": "{{name}} neu verbinden: {{formatted}} Quelle ist pausiert",
|
||||
"reconnectSources_other": "{{name}} neu verbinden: {{formatted}} Quellen sind pausiert",
|
||||
"reconnectTools": "{{name}} neu verbinden, damit Agenten es wieder nutzen können",
|
||||
"reconnectBoth_one": "{{name}} neu verbinden: {{formatted}} Quelle ist pausiert und Agenten können es nicht nutzen",
|
||||
"reconnectBoth_other": "{{name}} neu verbinden: {{formatted}} Quellen sind pausiert und Agenten können es nicht nutzen"
|
||||
},
|
||||
"error": {
|
||||
"decrypt": "Die gespeicherten Zugangsdaten konnten nicht entschlüsselt werden. Verbinde neu, um fortzufahren.",
|
||||
|
||||
@@ -1262,7 +1262,12 @@
|
||||
"failed": "Could not remove the connection. Try again."
|
||||
},
|
||||
"health": {
|
||||
"reconnect": "Reconnect {{name}} to keep syncing."
|
||||
"reconnect": "Reconnect {{name}} to keep syncing.",
|
||||
"reconnectSources_one": "Reconnect {{name}}: {{formatted}} source is paused",
|
||||
"reconnectSources_other": "Reconnect {{name}}: {{formatted}} sources are paused",
|
||||
"reconnectTools": "Reconnect {{name}} so agents can use it again",
|
||||
"reconnectBoth_one": "Reconnect {{name}}: {{formatted}} source is paused and agents can't use it",
|
||||
"reconnectBoth_other": "Reconnect {{name}}: {{formatted}} sources are paused and agents can't use it"
|
||||
},
|
||||
"error": {
|
||||
"decrypt": "Stored credentials could not be decrypted. Reconnect to continue.",
|
||||
|
||||
@@ -1256,7 +1256,12 @@
|
||||
"failed": "No se pudo eliminar la conexión. Inténtalo de nuevo."
|
||||
},
|
||||
"health": {
|
||||
"reconnect": "Reconecta {{name}} para seguir sincronizando."
|
||||
"reconnect": "Reconecta {{name}} para seguir sincronizando.",
|
||||
"reconnectSources_one": "Vuelve a conectar {{name}}: {{formatted}} fuente está en pausa",
|
||||
"reconnectSources_other": "Vuelve a conectar {{name}}: {{formatted}} fuentes están en pausa",
|
||||
"reconnectTools": "Vuelve a conectar {{name}} para que los agentes puedan usarlo de nuevo",
|
||||
"reconnectBoth_one": "Vuelve a conectar {{name}}: {{formatted}} fuente está en pausa y los agentes no pueden usarlo",
|
||||
"reconnectBoth_other": "Vuelve a conectar {{name}}: {{formatted}} fuentes están en pausa y los agentes no pueden usarlo"
|
||||
},
|
||||
"error": {
|
||||
"decrypt": "No se pudieron descifrar las credenciales guardadas. Reconecta para continuar.",
|
||||
|
||||
@@ -1242,7 +1242,10 @@
|
||||
"failed": "接続を削除できませんでした。もう一度お試しください。"
|
||||
},
|
||||
"health": {
|
||||
"reconnect": "同期を続けるには {{name}} を再接続してください。"
|
||||
"reconnect": "同期を続けるには {{name}} を再接続してください。",
|
||||
"reconnectSources_other": "{{name}} を再接続してください: {{formatted}} 件のソースが一時停止中です",
|
||||
"reconnectTools": "エージェントが再び使えるよう {{name}} を再接続してください",
|
||||
"reconnectBoth_other": "{{name}} を再接続してください: {{formatted}} 件のソースが一時停止中で、エージェントも使えません"
|
||||
},
|
||||
"error": {
|
||||
"decrypt": "保存された認証情報を復号できませんでした。続行するには再接続してください。",
|
||||
|
||||
@@ -1322,7 +1322,16 @@
|
||||
"failed": "Не удалось удалить подключение. Попробуйте ещё раз."
|
||||
},
|
||||
"health": {
|
||||
"reconnect": "Переподключите {{name}}, чтобы синхронизация продолжилась."
|
||||
"reconnect": "Переподключите {{name}}, чтобы синхронизация продолжилась.",
|
||||
"reconnectSources_one": "Переподключите {{name}}: {{formatted}} источник приостановлен",
|
||||
"reconnectSources_few": "Переподключите {{name}}: {{formatted}} источника приостановлены",
|
||||
"reconnectSources_many": "Переподключите {{name}}: {{formatted}} источников приостановлены",
|
||||
"reconnectSources_other": "Переподключите {{name}}: {{formatted}} источника приостановлены",
|
||||
"reconnectTools": "Переподключите {{name}}, чтобы агенты снова могли его использовать",
|
||||
"reconnectBoth_one": "Переподключите {{name}}: {{formatted}} источник приостановлен, агенты не могут его использовать",
|
||||
"reconnectBoth_few": "Переподключите {{name}}: {{formatted}} источника приостановлены, агенты не могут его использовать",
|
||||
"reconnectBoth_many": "Переподключите {{name}}: {{formatted}} источников приостановлены, агенты не могут его использовать",
|
||||
"reconnectBoth_other": "Переподключите {{name}}: {{formatted}} источника приостановлены, агенты не могут его использовать"
|
||||
},
|
||||
"error": {
|
||||
"decrypt": "Не удалось расшифровать сохранённые учётные данные. Переподключитесь, чтобы продолжить.",
|
||||
|
||||
@@ -1242,7 +1242,10 @@
|
||||
"failed": "無法移除此連線。請再試一次。"
|
||||
},
|
||||
"health": {
|
||||
"reconnect": "重新連線 {{name}} 以繼續同步。"
|
||||
"reconnect": "重新連線 {{name}} 以繼續同步。",
|
||||
"reconnectSources_other": "請重新連線 {{name}}:{{formatted}} 個來源已暫停",
|
||||
"reconnectTools": "請重新連線 {{name}},讓代理可以再次使用它",
|
||||
"reconnectBoth_other": "請重新連線 {{name}}:{{formatted}} 個來源已暫停,代理也無法使用它"
|
||||
},
|
||||
"error": {
|
||||
"decrypt": "無法解密已儲存的憑證。請重新連線以繼續。",
|
||||
|
||||
@@ -1242,7 +1242,10 @@
|
||||
"failed": "无法移除此连接。请重试。"
|
||||
},
|
||||
"health": {
|
||||
"reconnect": "重新连接 {{name}} 以继续同步。"
|
||||
"reconnect": "重新连接 {{name}} 以继续同步。",
|
||||
"reconnectSources_other": "请重新连接 {{name}}:{{formatted}} 个来源已暂停",
|
||||
"reconnectTools": "请重新连接 {{name}},让智能体可以再次使用它",
|
||||
"reconnectBoth_other": "请重新连接 {{name}}:{{formatted}} 个来源已暂停,智能体也无法使用它"
|
||||
},
|
||||
"error": {
|
||||
"decrypt": "无法解密保存的凭据。请重新连接以继续。",
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
opts && 'formatted' in opts ? `${key}:${opts.formatted}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const server = vi.hoisted(() => ({
|
||||
catalog: [] as unknown[],
|
||||
connections: [] as unknown[],
|
||||
}));
|
||||
vi.mock('../api/services/connectorsService', () => ({
|
||||
default: {
|
||||
getCatalog: vi.fn(async () => ({
|
||||
success: true,
|
||||
connectors: server.catalog,
|
||||
})),
|
||||
listConnections: vi.fn(async () => ({
|
||||
success: true,
|
||||
connections: server.connections,
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
const launch = vi.fn();
|
||||
vi.mock('../connectors/useConnectorLauncher', () => ({
|
||||
default: () => ({ launch, modals: null }),
|
||||
}));
|
||||
|
||||
import connectorsReducer from '../connectors/connectorsSlice';
|
||||
import ConnectionHealthToast from './ConnectionHealthToast';
|
||||
import notificationsReducer, { sseEventReceived } from './notificationsSlice';
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
const DRIVE = { key: 'google_drive', name: 'Google Drive', auth_kind: 'oauth' };
|
||||
|
||||
describe('ConnectionHealthToast', () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
launch.mockClear();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
const render = async (
|
||||
payload: Record<string, unknown>,
|
||||
connectionStatus = 'reconnect_needed',
|
||||
) => {
|
||||
server.catalog = [DRIVE];
|
||||
server.connections = [{ id: 'conn-1', status: connectionStatus }];
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
notifications: notificationsReducer,
|
||||
connectors: connectorsReducer,
|
||||
preference: (state = { token: null }) => state,
|
||||
},
|
||||
preloadedState: {
|
||||
connectors: {
|
||||
enabled: true,
|
||||
loading: false,
|
||||
loaded: true,
|
||||
failed: false,
|
||||
catalog: [DRIVE],
|
||||
connections: [{ id: 'conn-1', status: connectionStatus }],
|
||||
},
|
||||
preference: { token: null },
|
||||
},
|
||||
} as Parameters<typeof configureStore>[0]);
|
||||
store.dispatch(
|
||||
sseEventReceived({
|
||||
id: 'e1',
|
||||
type: 'connection.reconnect_needed',
|
||||
ts: new Date().toISOString(),
|
||||
scope: { kind: 'connection', id: 'conn-1' },
|
||||
payload: {
|
||||
connection_id: 'conn-1',
|
||||
connector_key: 'google_drive',
|
||||
name: 'Google Drive',
|
||||
...payload,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<ConnectionHealthToast />
|
||||
</MemoryRouter>
|
||||
</Provider>,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const title = () =>
|
||||
container.querySelector('[data-slot="toast-title"]')?.textContent;
|
||||
|
||||
it('says which sources are paused', async () => {
|
||||
await render({ source_count: 3, tool_count: 0 });
|
||||
expect(title()).toBe('settings.connectors.health.reconnectSources:3');
|
||||
});
|
||||
|
||||
it('says agents cannot use a tool-only connection', async () => {
|
||||
await render({ source_count: 0, tool_count: 2 });
|
||||
expect(title()).toBe('settings.connectors.health.reconnectTools:0');
|
||||
});
|
||||
|
||||
it('reconnects in place and stays until it works', async () => {
|
||||
await render({ source_count: 1, tool_count: 1 });
|
||||
const link = container.querySelector('a')!;
|
||||
await act(async () => link.click());
|
||||
expect(launch).toHaveBeenCalledWith(DRIVE, {
|
||||
mode: 'reconnect',
|
||||
connectionId: 'conn-1',
|
||||
});
|
||||
expect(title()).toBe('settings.connectors.health.reconnectBoth:1');
|
||||
});
|
||||
|
||||
it('closes itself once the connection works again', async () => {
|
||||
await render({ source_count: 1 }, 'connected');
|
||||
expect(container.querySelector('[data-slot="toast"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,14 @@ import {
|
||||
ToastHeader,
|
||||
ToastTitle,
|
||||
} from '../components/ui/toast';
|
||||
import { loadConnectors } from '../connectors/connectorsSlice';
|
||||
import {
|
||||
loadConnectors,
|
||||
selectConnections,
|
||||
selectConnectorCatalog,
|
||||
selectConnectorsLoaded,
|
||||
} from '../connectors/connectorsSlice';
|
||||
import useConnectorLauncher from '../connectors/useConnectorLauncher';
|
||||
import { formatCount } from '../utils/dateTimeUtils';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import type { AppDispatch } from '../store';
|
||||
import {
|
||||
@@ -27,11 +34,11 @@ const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
const MAX_VISIBLE = 2;
|
||||
|
||||
/**
|
||||
* "Reconnect Google Drive to keep syncing": a connection whose sign-in
|
||||
* stopped working (``connection.reconnect_needed``). Stays until the user
|
||||
* reconnects or dismisses it, since syncing is paused until then. Shares
|
||||
* the team notifications' persisted dismissals, so a reload does not pop it
|
||||
* again.
|
||||
* "Reconnect Google Drive: 3 sources paused": a connection whose sign-in
|
||||
* stopped working (``connection.reconnect_needed``). Reconnect signs in
|
||||
* again right here; the toast closes itself once the connection works, or
|
||||
* when dismissed. Shares the team notifications' persisted dismissals, so a
|
||||
* reload does not pop it again.
|
||||
*/
|
||||
export default function ConnectionHealthToast() {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
@@ -40,6 +47,10 @@ export default function ConnectionHealthToast() {
|
||||
const events = useSelector(selectRecentEvents);
|
||||
const dismissed = useSelector(selectDismissedShareNotifications);
|
||||
const dismissedSet = useMemo(() => new Set(dismissed), [dismissed]);
|
||||
const catalog = useSelector(selectConnectorCatalog);
|
||||
const connections = useSelector(selectConnections);
|
||||
const connectionsLoaded = useSelector(selectConnectorsLoaded);
|
||||
const { launch, modals } = useConnectorLauncher();
|
||||
|
||||
const onDismiss = useCallback(
|
||||
(id: string) => dispatch(dismissShareNotification(id)),
|
||||
@@ -58,6 +69,9 @@ export default function ConnectionHealthToast() {
|
||||
}
|
||||
const connectionId = String(event.scope?.id ?? event.id);
|
||||
if (seenConnections.has(connectionId)) continue;
|
||||
// Reconnected since (here or anywhere): nothing to say any more.
|
||||
const current = connections.find((c) => c.id === connectionId);
|
||||
if (connectionsLoaded && current?.status === 'connected') continue;
|
||||
seenConnections.add(connectionId);
|
||||
visible.push(event);
|
||||
if (visible.length >= MAX_VISIBLE) break;
|
||||
@@ -69,23 +83,47 @@ export default function ConnectionHealthToast() {
|
||||
if (newest) dispatch(loadConnectors({ token }));
|
||||
}, [newest, dispatch, token]);
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
if (visible.length === 0) return <>{modals}</>;
|
||||
|
||||
const title = (payload: Record<string, unknown>) => {
|
||||
const name = String(payload.name ?? '');
|
||||
const sources = Number(payload.source_count ?? 0);
|
||||
const tools = Number(payload.tool_count ?? 0);
|
||||
const values = {
|
||||
name,
|
||||
count: sources,
|
||||
formatted: formatCount(sources),
|
||||
interpolation: { escapeValue: false },
|
||||
};
|
||||
if (sources && tools)
|
||||
return t('settings.connectors.health.reconnectBoth', values);
|
||||
if (sources)
|
||||
return t('settings.connectors.health.reconnectSources', values);
|
||||
if (tools) return t('settings.connectors.health.reconnectTools', values);
|
||||
return t('settings.connectors.health.reconnect', values);
|
||||
};
|
||||
|
||||
const reconnect = (payload: Record<string, unknown>) => {
|
||||
const connectionId = String(payload.connection_id ?? '');
|
||||
const connector = catalog.find((c) => c.key === payload.connector_key);
|
||||
const inPlace =
|
||||
connector?.auth_kind === 'oauth' || connector?.auth_kind === 'api_key';
|
||||
if (connector && connectionId && inPlace) {
|
||||
launch(connector, { mode: 'reconnect', connectionId });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{visible.map((event) => {
|
||||
const payload = (event.payload ?? {}) as Record<string, unknown>;
|
||||
const name = String(payload.name ?? '');
|
||||
const key = String(payload.connector_key ?? '');
|
||||
return (
|
||||
<Toast key={event.id}>
|
||||
<ToastHeader variant="warning">
|
||||
<ToastTitle wrap>
|
||||
{t('settings.connectors.health.reconnect', {
|
||||
name,
|
||||
interpolation: { escapeValue: false },
|
||||
})}
|
||||
</ToastTitle>
|
||||
<ToastTitle wrap>{title(payload)}</ToastTitle>
|
||||
<ToastActions>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -99,10 +137,14 @@ export default function ConnectionHealthToast() {
|
||||
</ToastActions>
|
||||
</ToastHeader>
|
||||
<ToastFooter>
|
||||
{/* Sign in again right here; MCP servers reconnect from the
|
||||
connector's drawer. The toast stays until it works. */}
|
||||
<Button asChild size="sm" shape="pill">
|
||||
<Link
|
||||
to={`/settings/connectors?connector=${encodeURIComponent(key)}`}
|
||||
onClick={() => onDismiss(event.id as string)}
|
||||
onClick={(e) => {
|
||||
if (reconnect(payload)) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
{t('settings.connectors.status.reconnect')}
|
||||
</Link>
|
||||
@@ -111,6 +153,7 @@ export default function ConnectionHealthToast() {
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
{modals}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -157,10 +157,20 @@ class TestExecutor:
|
||||
with _service_db(pg_conn):
|
||||
pause = _pause(_executor(), _tool(cid))
|
||||
assert pause["pause_type"] == "awaiting_approval"
|
||||
# The caller's own connection: the card can reconnect it in place.
|
||||
assert pause["connection_required"] == {
|
||||
"connector_key": "telegram", "connector_name": "Telegram", "status": "reconnect_needed",
|
||||
"connection_id": cid, "owner_account": False,
|
||||
}
|
||||
|
||||
def test_owners_broken_account_is_not_handed_to_the_member(self, pg_conn):
|
||||
cid = _connection(pg_conn, status="reconnect_needed")
|
||||
with _service_db(pg_conn):
|
||||
pause = _pause(_executor(user="bob"), _tool(cid))
|
||||
required = pause["connection_required"]
|
||||
assert required["owner_account"] is True
|
||||
assert "connection_id" not in required
|
||||
|
||||
def test_member_without_connection_pauses(self, pg_conn):
|
||||
cid = _connection(pg_conn)
|
||||
with _service_db(pg_conn):
|
||||
|
||||
@@ -175,6 +175,9 @@ class TestGetValidTokenInfo:
|
||||
assert meta["sync_state"] == "paused_reconnect"
|
||||
publish.assert_called_once()
|
||||
assert publish.call_args.args[1] == "connection.reconnect_needed"
|
||||
# The toast says what stopped working.
|
||||
assert publish.call_args.args[2]["source_count"] == 1
|
||||
assert publish.call_args.args[2]["tool_count"] == 0
|
||||
# No account data or secrets in the event.
|
||||
assert "alice@example.com" not in json.dumps(publish.call_args.args[2])
|
||||
|
||||
|
||||
Reference in new issue
Block a user