From 6cbd6091d4ba1a4f9a8be0443df728a0929ceaf2 Mon Sep 17 00:00:00 2001 From: arc53-machine <232052973+arc53-machine@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:55:45 +0100 Subject: [PATCH] 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. --- frontend/src/Hero.test.tsx | 121 ++++++++++++++++++ frontend/src/Hero.tsx | 56 +++++++- frontend/src/Navigation.tsx | 2 + .../connectors/ConnectionHealthDot.test.tsx | 67 ++++++++++ .../src/connectors/ConnectionHealthDot.tsx | 23 ++++ frontend/src/connectors/connectorsSlice.ts | 6 + frontend/src/hooks/useDataInitializer.ts | 17 ++- frontend/src/locale/de.json | 7 +- frontend/src/locale/en.json | 7 +- frontend/src/locale/es.json | 7 +- frontend/src/locale/jp.json | 7 +- frontend/src/locale/ru.json | 7 +- frontend/src/locale/zh-TW.json | 7 +- frontend/src/locale/zh.json | 7 +- frontend/src/navigation/SectionNav.tsx | 2 + frontend/src/settings/Connectors.test.tsx | 10 ++ frontend/src/settings/Connectors.tsx | 27 ++-- 17 files changed, 357 insertions(+), 23 deletions(-) create mode 100644 frontend/src/Hero.test.tsx create mode 100644 frontend/src/connectors/ConnectionHealthDot.test.tsx create mode 100644 frontend/src/connectors/ConnectionHealthDot.tsx diff --git a/frontend/src/Hero.test.tsx b/frontend/src/Hero.test.tsx new file mode 100644 index 00000000..ca1dfe97 --- /dev/null +++ b/frontend/src/Hero.test.tsx @@ -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) => { + 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[0]); + await act(async () => { + root.render( + + + + } /> + CONNECTORS} + /> + + + , + ); + }); + }; + + const card = () => + container.querySelector( + '[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'); + }); +}); diff --git a/frontend/src/Hero.tsx b/frontend/src/Hero.tsx index bba6aebd..3b706776 100644 --- a/frontend/src/Hero.tsx +++ b/frontend/src/Hero.tsx @@ -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 (
@@ -170,7 +199,7 @@ export default function Hero({ {/* Demo Buttons Section */}
- {demos?.map( + {shown.map( (demo: { header: string; query: string }, key: number) => demo.header && demo.query && ( @@ -199,6 +228,31 @@ export default function Hero({ ), )} + {offerConnect && ( + + )}
diff --git a/frontend/src/Navigation.tsx b/frontend/src/Navigation.tsx index 690d2bd0..31ba2c69 100644 --- a/frontend/src/Navigation.tsx +++ b/frontend/src/Navigation.tsx @@ -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 />

{t('settings.label')}

+ diff --git a/frontend/src/connectors/ConnectionHealthDot.test.tsx b/frontend/src/connectors/ConnectionHealthDot.test.tsx new file mode 100644 index 00000000..7b950f63 --- /dev/null +++ b/frontend/src/connectors/ConnectionHealthDot.test.tsx @@ -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[0]); + await act(async () => { + root.render( + + + , + ); + }); + }; + + 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(''); + }); +}); diff --git a/frontend/src/connectors/ConnectionHealthDot.tsx b/frontend/src/connectors/ConnectionHealthDot.tsx new file mode 100644 index 00000000..9b83179e --- /dev/null +++ b/frontend/src/connectors/ConnectionHealthDot.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/connectors/connectorsSlice.ts b/frontend/src/connectors/connectorsSlice.ts index e6771ab2..5ab08500 100644 --- a/frontend/src/connectors/connectorsSlice.ts +++ b/frontend/src/connectors/connectorsSlice.ts @@ -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) => diff --git a/frontend/src/hooks/useDataInitializer.ts b/frontend/src/hooks/useDataInitializer.ts index b78501b3..e56c002a 100644 --- a/frontend/src/hooks/useDataInitializer.ts +++ b/frontend/src/hooks/useDataInitializer.ts @@ -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(); 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(() => { diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 402b27dc..1bfaebdc 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -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." } } diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index c52108a5..2281af8f 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -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." } } diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 5cc21454..767ce974 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -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." } } diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 05291fcf..e634e8bd 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -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}} などとチャットできます。" } } diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index 53ce062d..7869d5b9 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -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}} и не только." } } diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 8f617f93..abb97e28 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -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}} 等進行對話。" } } diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 29071cee..b6865ab8 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -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}} 等进行对话。" } } diff --git a/frontend/src/navigation/SectionNav.tsx b/frontend/src/navigation/SectionNav.tsx index 71abcfe0..98792f83 100644 --- a/frontend/src/navigation/SectionNav.tsx +++ b/frontend/src/navigation/SectionNav.tsx @@ -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({ > {t(item.labelKey)} + {item.path === '/settings/connectors' && } ); diff --git a/frontend/src/settings/Connectors.test.tsx b/frontend/src/settings/Connectors.test.tsx index dd8dd411..b301be8a 100644 --- a/frontend/src/settings/Connectors.test.tsx +++ b/frontend/src/settings/Connectors.test.tsx @@ -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(); diff --git a/frontend/src/settings/Connectors.tsx b/frontend/src/settings/Connectors.tsx index 0916793e..1c402780 100644 --- a/frontend/src/settings/Connectors.tsx +++ b/frontend/src/settings/Connectors.tsx @@ -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') {