Point people to connecting their data

- The empty chat screen swaps its last demo card for "Connect your
  data" while nothing is connected, naming services this install can
  connect.
- A warning dot on Settings and on Settings > Connectors marks a
  connection that needs signing in again; unlike the toast it stays until
  the connection is fixed. Connections load once at start for it.
- Category pills on the Connectors page follow the composer's capability
  filter, so none leads to an empty page.
This commit is contained in:
arc53-machine committed 2026-09-28 21:55:45 +01:00
1 parent df70869dc7
commit 6cbd6091d4
17 files changed
+357 -23

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
import { configureStore } from '@reduxjs/toolkit';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Provider } from 'react-redux';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => {
if (key === 'demo')
return [1, 2, 3, 4].map((n) => ({
header: `demo${n}`,
query: `q${n}`,
}));
return opts?.names ? `${key}:${opts.names}` : key;
},
}),
}));
vi.mock('./api/services/modelService', () => ({
default: {
getModels: vi.fn(async () => ({ ok: true, json: async () => ({}) })),
transformModels: () => [],
},
}));
vi.mock('./hooks', () => ({ useDarkTheme: () => [false] }));
import connectorsReducer from './connectors/connectorsSlice';
import Hero from './Hero';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const connector = (key: string, name: string, publisher = 'built_in') => ({
key,
name,
publisher,
available: true,
});
describe('Hero connect card', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async (connections: unknown[]) => {
const store = configureStore({
reducer: {
connectors: connectorsReducer,
preference: (
state = {
token: null,
selectedModel: null,
availableModels: [],
modelsLoading: false,
},
) => state,
},
preloadedState: {
connectors: {
enabled: true,
loading: false,
loaded: true,
failed: false,
catalog: [
connector('google_drive', 'Google Drive'),
connector('mcp:notion', 'Notion', 'preset'),
connector('custom_mcp', 'MCP server', 'custom'),
],
connections,
},
},
} as Parameters<typeof configureStore>[0]);
await act(async () => {
root.render(
<Provider store={store}>
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path="/" element={<Hero handleQuestion={vi.fn()} />} />
<Route
path="/settings/connectors"
element={<div>CONNECTORS</div>}
/>
</Routes>
</MemoryRouter>
</Provider>,
);
});
};
const card = () =>
container.querySelector<HTMLButtonElement>(
'[data-testid="hero-connect-card"]',
);
it('offers connecting the services this install has', async () => {
await render([]);
expect(card()?.textContent).toContain(
'connectHero.body:Google Drive, Notion',
);
// It replaces the last demo card.
expect(container.textContent).not.toContain('demo4');
await act(async () => card()!.click());
expect(container.textContent).toContain('CONNECTORS');
});
it('keeps the demo cards once something is connected', async () => {
await render([{ id: 'c1', status: 'connected' }]);
expect(card()).toBeNull();
expect(container.textContent).toContain('demo4');
});
});
+55 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import modelService from './api/services/modelService';
@@ -14,6 +15,13 @@ import {
SelectTrigger,
SelectValue,
} from './components/ui/select';
import {
selectConnections,
selectConnectorCatalog,
selectConnectorsEnabled,
selectConnectorsLoaded,
} from './connectors/connectorsSlice';
import { connectorName } from './connectors/i18n';
import { useDarkTheme } from './hooks';
import {
selectAvailableModels,
@@ -145,10 +153,31 @@ export default function Hero({
}) {
const { t } = useTranslation();
const [isDarkTheme] = useDarkTheme();
const navigate = useNavigate();
const demos = t('demo', { returnObjects: true }) as Array<{
header: string;
query: string;
}>;
// Nothing connected yet: the last card offers connecting a service, named
// after what this install can actually connect.
const connectorsEnabled = useSelector(selectConnectorsEnabled);
const connectorsLoaded = useSelector(selectConnectorsLoaded);
const connections = useSelector(selectConnections);
const catalog = useSelector(selectConnectorCatalog);
const connectable = catalog.filter(
(connector) => connector.available && connector.publisher !== 'custom',
);
const offerConnect =
connectorsEnabled &&
connectorsLoaded &&
connections.length === 0 &&
connectable.length > 0;
const connectNames = connectable
.slice(0, 2)
.map((connector) => connectorName(t, connector))
.join(', ');
const cards = (demos ?? []).filter((demo) => demo.header && demo.query);
const shown = offerConnect ? cards.slice(0, 3) : cards;
return (
<div className="text-foreground flex h-full w-full flex-col items-center justify-between">
@@ -170,7 +199,7 @@ export default function Hero({
{/* Demo Buttons Section */}
<div className="mb-3 w-full max-w-full md:mb-3">
<div className="grid grid-cols-1 gap-3 text-xs md:grid-cols-1 md:gap-4 lg:grid-cols-2">
{demos?.map(
{shown.map(
(demo: { header: string; query: string }, key: number) =>
demo.header &&
demo.query && (
@@ -199,6 +228,31 @@ export default function Hero({
</Button>
),
)}
{offerConnect && (
<Button
type="button"
variant="outline"
size="lg"
shape="pill"
onClick={() => navigate('/settings/connectors')}
data-testid="hero-connect-card"
className={cn(
/* eslint-disable-next-line shadcn/no-restyle --
Same two-line pill as the demo cards above. */
'hidden h-auto w-full flex-col items-start gap-0 py-3.5 text-left text-xs font-normal whitespace-normal md:flex',
)}
>
<p className="text-foreground mb-2 font-semibold">
{t('connectHero.title')}
</p>
<span className="text-muted-foreground line-clamp-2">
{t('connectHero.body', {
names: connectNames,
interpolation: { escapeValue: false },
})}
</span>
</Button>
)}
</div>
</div>
</div>
+2
View File
@@ -55,6 +55,7 @@ import {
getSectionForPath,
type Section,
} from './navigation/sections';
import ConnectionHealthDot from './connectors/ConnectionHealthDot';
import { useSidebarLevel } from './navigation/SidebarLevelProvider';
import { useSectionContext } from './navigation/useSectionContext';
import { useLastAppPath } from './navigation/useLastAppPath';
@@ -798,6 +799,7 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
aria-hidden
/>
<p className="text-foreground text-sm">{t('settings.label')}</p>
<ConnectionHealthDot />
</Link>
</Button>
</div>
@@ -0,0 +1,67 @@
import { configureStore } from '@reduxjs/toolkit';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Provider } from 'react-redux';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import ConnectionHealthDot from './ConnectionHealthDot';
import connectorsReducer from './connectorsSlice';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('ConnectionHealthDot', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async (statuses: string[]) => {
const store = configureStore({
reducer: { connectors: connectorsReducer },
preloadedState: {
connectors: {
enabled: true,
loading: false,
loaded: true,
failed: false,
catalog: [],
connections: statuses.map((status, i) => ({ id: `c${i}`, status })),
},
},
} as Parameters<typeof configureStore>[0]);
await act(async () => {
root.render(
<Provider store={store}>
<ConnectionHealthDot />
</Provider>,
);
});
};
it('shows while a connection needs signing in again', async () => {
await render(['connected', 'reconnect_needed']);
const dot = container.querySelector(
'[data-testid="connection-health-dot"]',
);
expect(dot?.getAttribute('aria-label')).toBe(
'settings.connectors.health.navDot',
);
});
it('stays away when every connection works', async () => {
await render(['connected', 'disconnected']);
expect(container.innerHTML).toBe('');
});
});
@@ -0,0 +1,23 @@
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { selectConnectionsNeedAttention } from './connectorsSlice';
/**
* A warning dot on the nav entries that lead to Connectors while a
* connection needs signing in again. Unlike the reconnect toast it does not
* expire, so the signal stays until the connection is fixed.
*/
export default function ConnectionHealthDot() {
const { t } = useTranslation();
const needsAttention = useSelector(selectConnectionsNeedAttention);
if (!needsAttention) return null;
return (
<span
data-testid="connection-health-dot"
role="img"
aria-label={t('settings.connectors.health.navDot')}
className="bg-warning ml-auto size-2 shrink-0 rounded-full"
/>
);
}
@@ -86,6 +86,12 @@ export const selectConnectorCatalog = (state: RootLike) =>
state.connectors.catalog;
export const selectConnections = (state: RootLike) =>
state.connectors.connections;
/** A connection that needs the user (signing in again, or failing). */
export const selectConnectionsNeedAttention = (state: RootLike) =>
(state.connectors?.connections ?? []).some(
(connection) =>
connection.status === 'reconnect_needed' || connection.status === 'error',
);
export const selectConnectorsLoading = (state: RootLike) =>
state.connectors.loading;
export const selectConnectorsLoaded = (state: RootLike) =>
+15 -2
View File
@@ -2,7 +2,11 @@ import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import userService from '../api/services/userService';
import { setConnectorsEnabled } from '../connectors/connectorsSlice';
import {
loadConnectors,
selectConnectorsEnabled,
setConnectorsEnabled,
} from '../connectors/connectorsSlice';
import { claimLegacySessionTokens } from '../utils/providerUtils';
import {
getDocs,
@@ -17,6 +21,7 @@ import {
setSourceDocs,
setSpeechAvailability,
} from '../preferences/preferenceSlice';
import type { AppDispatch } from '../store';
/**
* useDataInitializer Hook
@@ -31,7 +36,7 @@ import {
* @param isAuthLoading -
*/
export default function useDataInitializer(isAuthLoading: boolean) {
const dispatch = useDispatch();
const dispatch = useDispatch<AppDispatch>();
const token = useSelector(selectToken);
const conversations = useSelector(selectConversations);
@@ -53,6 +58,14 @@ export default function useDataInitializer(isAuthLoading: boolean) {
.catch(() => undefined);
}, [dispatch]);
// Connections load once at start so the nav can flag one that needs
// signing in again before any connectors page is opened.
const connectorsEnabled = useSelector(selectConnectorsEnabled);
useEffect(() => {
if (isAuthLoading || !connectorsEnabled) return;
dispatch(loadConnectors({ token }));
}, [isAuthLoading, connectorsEnabled, token, dispatch]);
// Connector sign-ins used to leave a session token in localStorage. Link
// each one to its server-side connection once, then forget it.
useEffect(() => {
+6 -1
View File
@@ -1263,7 +1263,8 @@
"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"
"reconnectBoth_other": "{{name}} neu verbinden: {{formatted}} Quellen sind pausiert und Agenten können es nicht nutzen",
"navDot": "Eine Verbindung braucht Aufmerksamkeit"
},
"error": {
"decrypt": "Die gespeicherten Zugangsdaten konnten nicht entschlüsselt werden. Verbinde neu, um fortzufahren.",
@@ -2587,5 +2588,9 @@
},
"prompts": {
"textAriaLabel": "Prompt-Text"
},
"connectHero": {
"title": "Daten verbinden",
"body": "Chatte mit {{names}} und mehr."
}
}
+6 -1
View File
@@ -1269,7 +1269,8 @@
"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"
"reconnectBoth_other": "Reconnect {{name}}: {{formatted}} sources are paused and agents can't use it",
"navDot": "A connection needs attention"
},
"error": {
"decrypt": "Stored credentials could not be decrypted. Reconnect to continue.",
@@ -2638,5 +2639,9 @@
"model": "{{model}} ({{tokens}} tokens)"
}
}
},
"connectHero": {
"title": "Connect your data",
"body": "Chat with {{names}} and more."
}
}
+6 -1
View File
@@ -1263,7 +1263,8 @@
"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"
"reconnectBoth_other": "Vuelve a conectar {{name}}: {{formatted}} fuentes están en pausa y los agentes no pueden usarlo",
"navDot": "Una conexión necesita atención"
},
"error": {
"decrypt": "No se pudieron descifrar las credenciales guardadas. Reconecta para continuar.",
@@ -2587,5 +2588,9 @@
},
"prompts": {
"textAriaLabel": "Texto del prompt"
},
"connectHero": {
"title": "Conecta tus datos",
"body": "Chatea con {{names}} y más."
}
}
+6 -1
View File
@@ -1247,7 +1247,8 @@
"reconnect": "同期を続けるには {{name}} を再接続してください。",
"reconnectSources_other": "{{name}} を再接続してください: {{formatted}} 件のソースが一時停止中です",
"reconnectTools": "エージェントが再び使えるよう {{name}} を再接続してください",
"reconnectBoth_other": "{{name}} を再接続してください: {{formatted}} 件のソースが一時停止中で、エージェントも使えません"
"reconnectBoth_other": "{{name}} を再接続してください: {{formatted}} 件のソースが一時停止中で、エージェントも使えません",
"navDot": "接続に対応が必要です"
},
"error": {
"decrypt": "保存された認証情報を復号できませんでした。続行するには再接続してください。",
@@ -2566,5 +2567,9 @@
},
"prompts": {
"textAriaLabel": "プロンプトテキスト"
},
"connectHero": {
"title": "データを接続",
"body": "{{names}} などとチャットできます。"
}
}
+6 -1
View File
@@ -1333,7 +1333,8 @@
"reconnectBoth_one": "Переподключите {{name}}: {{formatted}} источник приостановлен, агенты не могут его использовать",
"reconnectBoth_few": "Переподключите {{name}}: {{formatted}} источника приостановлены, агенты не могут его использовать",
"reconnectBoth_many": "Переподключите {{name}}: {{formatted}} источников приостановлены, агенты не могут его использовать",
"reconnectBoth_other": "Переподключите {{name}}: {{formatted}} источника приостановлены, агенты не могут его использовать"
"reconnectBoth_other": "Переподключите {{name}}: {{formatted}} источника приостановлены, агенты не могут его использовать",
"navDot": "Подключению требуется внимание"
},
"error": {
"decrypt": "Не удалось расшифровать сохранённые учётные данные. Переподключитесь, чтобы продолжить.",
@@ -2681,5 +2682,9 @@
},
"prompts": {
"textAriaLabel": "Текст подсказки"
},
"connectHero": {
"title": "Подключите данные",
"body": "Общайтесь с {{names}} и не только."
}
}
+6 -1
View File
@@ -1247,7 +1247,8 @@
"reconnect": "重新連線 {{name}} 以繼續同步。",
"reconnectSources_other": "請重新連線 {{name}}:{{formatted}} 個來源已暫停",
"reconnectTools": "請重新連線 {{name}},讓代理可以再次使用它",
"reconnectBoth_other": "請重新連線 {{name}}:{{formatted}} 個來源已暫停,代理也無法使用它"
"reconnectBoth_other": "請重新連線 {{name}}:{{formatted}} 個來源已暫停,代理也無法使用它",
"navDot": "有連線需要處理"
},
"error": {
"decrypt": "無法解密已儲存的憑證。請重新連線以繼續。",
@@ -2566,5 +2567,9 @@
},
"prompts": {
"textAriaLabel": "提示文字"
},
"connectHero": {
"title": "連線你的資料",
"body": "與 {{names}} 等進行對話。"
}
}
+6 -1
View File
@@ -1247,7 +1247,8 @@
"reconnect": "重新连接 {{name}} 以继续同步。",
"reconnectSources_other": "请重新连接 {{name}}:{{formatted}} 个来源已暂停",
"reconnectTools": "请重新连接 {{name}},让智能体可以再次使用它",
"reconnectBoth_other": "请重新连接 {{name}}:{{formatted}} 个来源已暂停,智能体也无法使用它"
"reconnectBoth_other": "请重新连接 {{name}}:{{formatted}} 个来源已暂停,智能体也无法使用它",
"navDot": "有连接需要处理"
},
"error": {
"decrypt": "无法解密保存的凭据。请重新连接以继续。",
@@ -2566,5 +2567,9 @@
},
"prompts": {
"textAriaLabel": "提示文本"
},
"connectHero": {
"title": "连接你的数据",
"body": "与 {{names}} 等进行对话。"
}
}
+2
View File
@@ -5,6 +5,7 @@ import { Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import ConnectionHealthDot from '../connectors/ConnectionHealthDot';
import { selectConnectorsEnabled } from '../connectors/connectorsSlice';
import { getVisibleGroups, type Section, type SectionItem } from './sections';
import { useSidebarLevel } from './SidebarLevelProvider';
@@ -69,6 +70,7 @@ export default function SectionNav({
>
<Icon className="text-muted-foreground size-5 shrink-0" aria-hidden />
<span className="truncate">{t(item.labelKey)}</span>
{item.path === '/settings/connectors' && <ConnectionHealthDot />}
</Link>
</Button>
);
+10
View File
@@ -236,6 +236,16 @@ describe('Connectors page', () => {
expect(pills).not.toContain('settings.connectors.categories.database');
});
it('offers no category that the capability filter would leave empty', async () => {
await render('/settings/connectors?capability=sync');
const pills = Array.from(
container.querySelectorAll('[data-slot="toggle-group-item"]'),
).map((item) => item.textContent);
expect(pills).toContain('settings.connectors.categories.files');
// Telegram (messaging) cannot sync.
expect(pills).not.toContain('settings.connectors.categories.messaging');
});
it('shows a retry when the catalog fails to load', async () => {
service.getCatalog.mockResolvedValue({ success: false });
await render();
+14 -13
View File
@@ -98,27 +98,28 @@ export default function Connectors() {
setSearchParams(searchParams, { replace: true });
};
// Only categories that have something in them (connectors that need admin
// setup are not listed, which can empty one).
// Only categories that have something in them once the composer's
// capability filter applies (hidden connectors can empty one too), so no
// pill leads to an empty page.
const withCapability = catalog.filter((connector) =>
capability === 'sync'
? connector.capabilities.includes('sync')
: capability === 'tools'
? connector.capabilities.some((c) => c !== 'sync')
: true,
);
const filters = FILTERS.filter(
(key) =>
key === 'all' ||
key === filter ||
(key === 'connected'
? catalog.some(isConnected)
: catalog.some((connector) => connector.category === key)),
? withCapability.some(isConnected)
: withCapability.some((connector) => connector.category === key)),
);
const visible = useMemo(() => {
const query = search.trim().toLowerCase();
return catalog
.filter((connector) =>
capability === 'sync'
? connector.capabilities.includes('sync')
: capability === 'tools'
? connector.capabilities.some((c) => c !== 'sync')
: true,
)
return withCapability
.filter((connector) => {
if (filter === 'connected') return isConnected(connector);
if (filter !== 'all') return connector.category === filter;
@@ -131,7 +132,7 @@ export default function Connectors() {
connectorDescription(t, connector).toLowerCase().includes(query),
)
.sort((a, b) => STATE_ORDER[a.state] - STATE_ORDER[b.state]);
}, [catalog, filter, search, t, capability]);
}, [withCapability, filter, search, t]);
const open = (connector: ConnectorDefinition) => {
if (connector.state === 'available' || connector.state === 'custom') {