Group Add Source and Add Tool by where things come from

Add Source splits into Upload and web, and From a connection. Connector
tiles are always shown and say whether an account is connected or the
server still needs setup, instead of disappearing when a VITE_ client
id is unset; without the Google Picker key, Drive falls back to the
server-side file browser. The name follows the first picked item and
the button reads Add source.

Add Tool splits into Built in and From a service. The MCP and API tool
cards move to Connectors > Add custom connector.
This commit is contained in:
arc53-machine committed 2026-09-28 16:30:56 +01:00
1 parent aad43c2fd0
commit 45c4d928c4
7 files changed
+389 -116

No files matched your search

+11
View File
@@ -75,6 +75,8 @@ interface CloudFilePickerProps {
selectedFolderIds?: string[],
) => void;
onDisconnect?: () => void;
/** Called with the first item's name when the selection goes from empty to one. */
onFirstPickName?: (name: string) => void;
provider: string;
token: string | null;
initialSelectedFiles?: string[];
@@ -84,6 +86,7 @@ interface CloudFilePickerProps {
export const FilePicker: React.FC<CloudFilePickerProps> = ({
onSelectionChange,
onDisconnect,
onFirstPickName,
provider,
token,
initialSelectedFiles = [],
@@ -404,6 +407,14 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
};
const handleFileSelect = (fileId: string, isFolder: boolean) => {
if (
selectedFiles.length === 0 &&
selectedFolders.length === 0 &&
onFirstPickName
) {
const picked = files.find((file) => file.id === fileId);
if (picked) onFirstPickName(picked.name);
}
if (isFolder) {
const newSelectedFolders = selectedFolders.includes(fileId)
? selectedFolders.filter((id) => id !== fileId)
@@ -33,11 +33,14 @@ interface PickerFile {
interface GoogleDrivePickerProps {
token: string | null;
onSelectionChange: (fileIds: string[], folderIds?: string[]) => void;
/** Called with the first item's name when the selection goes from empty to one. */
onFirstPickName?: (name: string) => void;
}
const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
token,
onSelectionChange,
onFirstPickName,
}) => {
const { t } = useTranslation();
const [selectedFiles, setSelectedFiles] = useState<PickerFile[]>([]);
@@ -191,6 +194,13 @@ const GoogleDrivePicker: React.FC<GoogleDrivePickerProps> = ({
);
return [...prevFolders, ...uniqueNewFolders];
});
if (
selectedFiles.length === 0 &&
selectedFolders.length === 0 &&
docs.length > 0
) {
onFirstPickName?.(docs[0].name);
}
onSelectionChange(
[...selectedFiles, ...newFiles].map((file) => file.id),
[...selectedFolders, ...newFolders].map((folder) => folder.id),
+90 -72
View File
@@ -1,18 +1,21 @@
import { ArrowRight, Plus } from 'lucide-react';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import userService from '../api/services/userService';
import SkeletonLoader from '../components/SkeletonLoader';
import ToolIcon from '../components/ToolIcon';
import { Button } from '../components/ui/button';
import { Card, CardDescription, CardTitle } from '../components/ui/card';
import { Modal } from '../components/ui/modal';
import { SectionHeader } from '../components/ui/section-header';
import { useLoaderState } from '../hooks';
import PairDeviceModal from '../settings/PairDeviceModal';
import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
import ConfigToolModal from './ConfigToolModal';
import MCPServerModal from './MCPServerModal';
import { AvailableToolType } from './types';
export default function AddToolModal({
@@ -39,8 +42,6 @@ export default function AddToolModal({
React.useState<AvailableToolType | null>(null);
const [configModalState, setConfigModalState] =
React.useState<ActiveState>('INACTIVE');
const [mcpModalState, setMcpModalState] =
React.useState<ActiveState>('INACTIVE');
const [pairModalState, setPairModalState] =
React.useState<ActiveState>('INACTIVE');
const [loading, setLoading] = useLoaderState(false);
@@ -53,7 +54,11 @@ export default function AddToolModal({
return res.json();
})
.then((data) => {
setAvailableTools(data.data);
setAvailableTools(
(data.data as AvailableToolType[]).filter(
(tool) => tool.group !== 'custom',
),
);
setLoading(false);
});
};
@@ -96,9 +101,6 @@ export default function AddToolModal({
.catch((error) => {
console.error('Failed to create tool:', error);
});
} else if (tool.name === 'mcp_tool') {
setModalState('INACTIVE');
setMcpModalState('ACTIVE');
} else {
setModalState('INACTIVE');
setConfigModalState('ACTIVE');
@@ -109,11 +111,6 @@ export default function AddToolModal({
if (modalState === 'ACTIVE') getAvailableTools();
}, [modalState]);
const handleMcpServerAdded = () => {
getUserTools();
setMcpModalState('INACTIVE');
};
return (
<>
<Modal
@@ -122,62 +119,88 @@ export default function AddToolModal({
title={t('settings.tools.selectToolSetup')}
size="xl"
>
<div className="flex h-full flex-col">
<div>
<div className="mt-5 px-3 py-px">
{loading ? (
<div className="grid auto-rows-fr grid-cols-1 gap-4 pb-2 sm:grid-cols-2 lg:grid-cols-3">
<SkeletonLoader component="addToolCards" count={6} />
</div>
) : (
<div className="grid auto-rows-fr grid-cols-1 gap-4 pb-2 sm:grid-cols-2 lg:grid-cols-3">
{availableTools.map((tool, index) => (
<Card
asChild
key={index}
variant="outline"
padding="lg"
interactive
className="h-52 w-full justify-between"
>
<button
type="button"
onClick={() => {
setSelectedTool(tool);
handleAddTool(tool);
}}
>
<div className="w-full">
<div className="flex w-full items-center justify-between px-1">
<ToolIcon
name={tool.name}
className="size-6"
title={t('settings.tools.toolIconTitle', {
name: tool.name,
})}
/>
</div>
<div className="mt-[9px] px-1">
<CardTitle
title={tool.displayName}
className="truncate capitalize"
>
{tool.displayName}
</CardTitle>
<CardDescription
size="xs"
className="mt-1 h-24 overflow-auto"
>
{tool.description}
</CardDescription>
</div>
</div>
</button>
</Card>
))}
</div>
)}
<div className="flex flex-col gap-6">
{loading ? (
<div className="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<SkeletonLoader component="addToolCards" count={6} />
</div>
) : (
(['built_in', 'service'] as const).map((group) => {
const tools = availableTools.filter(
(tool) => (tool.group ?? 'built_in') === group,
);
if (tools.length === 0) return null;
return (
<section key={group} className="flex flex-col gap-3">
<SectionHeader
as="h3"
size="sm"
title={
group === 'built_in'
? t('settings.tools.groupBuiltIn')
: t('settings.tools.groupService')
}
/>
<div className="grid auto-rows-fr grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tools.map((tool) => (
<Card
asChild
key={tool.name}
variant="outline"
padding="lg"
interactive
className="h-44 w-full"
>
<button
type="button"
onClick={() => {
setSelectedTool(tool);
handleAddTool(tool);
}}
>
<ToolIcon
name={tool.name}
className="size-6"
title={t('settings.tools.toolIconTitle', {
name: tool.displayName,
})}
/>
<CardTitle
title={tool.displayName}
className="truncate capitalize"
>
{tool.displayName}
</CardTitle>
<CardDescription size="xs" className="line-clamp-3">
{tool.description}
</CardDescription>
</button>
</Card>
))}
</div>
</section>
);
})
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<Button variant="link" size="inline" asChild>
<Link
to="/settings/connectors"
onClick={() => setModalState('INACTIVE')}
>
{t('settings.tools.browseConnectors')}
<ArrowRight className="size-3" />
</Link>
</Button>
<Button variant="outline-primary" shape="pill" size="sm" asChild>
<Link
to="/settings/connectors?filter=custom"
onClick={() => setModalState('INACTIVE')}
>
<Plus />
{t('settings.connectors.addCustom')}
</Link>
</Button>
</div>
</div>
</Modal>
@@ -187,11 +210,6 @@ export default function AddToolModal({
tool={selectedTool}
getUserTools={getUserTools}
/>
<MCPServerModal
modalState={mcpModalState}
setModalState={setMcpModalState}
onServerSaved={handleMcpServerAdded}
/>
<PairDeviceModal
modalState={pairModalState}
setModalState={setPairModalState}
+3
View File
@@ -24,4 +24,7 @@ export type AvailableToolType = {
description: string;
parameters: object;
}[];
/** `built_in`, `service` (a connector's tool) or `custom` (MCP / OpenAPI). */
group?: 'built_in' | 'service' | 'custom';
connector_key?: string | null;
};
+86 -1
View File
@@ -5,8 +5,29 @@ vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const connectorsState = vi.hoisted(() => ({
catalog: [] as Record<string, unknown>[],
connections: [] as Record<string, unknown>[],
}));
vi.mock('react-redux', () => ({
useSelector: () => null,
useSelector: (selector: (state: unknown) => unknown) => {
const state = {
preference: { token: null, selectedDocs: [] },
connectors: {
catalog: connectorsState.catalog,
connections: connectorsState.connections,
loaded: true,
loading: false,
failed: false,
},
};
try {
return selector(state);
} catch {
return null;
}
},
useDispatch: () => vi.fn(),
useStore: () => ({ getState: () => ({}) }),
}));
@@ -84,6 +105,70 @@ describe('Upload source-type tiles', () => {
);
});
it('groups upload and web apart from connections', async () => {
await render();
expect(document.body.textContent).toContain(
'modals.uploadDoc.groupUploadWeb',
);
expect(document.body.textContent).toContain(
'modals.uploadDoc.groupConnection',
);
});
it('shows connector tiles even when the server is not set up', async () => {
connectorsState.catalog = [
{
key: 'google_drive',
icon: 'drive',
sync_ingestor: 'google_drive',
available: false,
missing_settings: ['GOOGLE_CLIENT_ID'],
},
];
await render();
const drive = tiles().find((tile) =>
tile.textContent?.includes(
'modals.uploadDoc.ingestors.google_drive.label',
),
);
expect(drive).toBeDefined();
expect(drive!.textContent).toContain(
'settings.connectors.status.needsAdminSetup',
);
await act(async () => drive!.click());
expect(document.body.textContent).toContain('GOOGLE_CLIENT_ID');
connectorsState.catalog = [];
});
it('names the connected account on a connection tile', async () => {
connectorsState.catalog = [
{
key: 'confluence',
icon: 'confluence',
sync_ingestor: 'confluence',
available: true,
missing_settings: [],
},
];
connectorsState.connections = [
{
id: 'c1',
connector_key: 'confluence',
status: 'connected',
account_label: 'alex@example.com',
},
];
await render();
const confluence = tiles().find((tile) =>
tile.textContent?.includes('modals.uploadDoc.ingestors.confluence.label'),
);
expect(confluence!.textContent).toContain(
'modals.uploadDoc.tileConnectedAs',
);
connectorsState.catalog = [];
connectorsState.connections = [];
});
it('leaves the disabled Train button on the default variant', async () => {
await render();
const crawler = tiles().find((tile) =>
+173 -29
View File
@@ -1,4 +1,4 @@
import { ChevronLeft } from 'lucide-react';
import { ChevronLeft, CircleAlert } from 'lucide-react';
import { envVar } from '@/env';
import { cn } from '@/lib/utils';
import { useCallback, useEffect, useState } from 'react';
@@ -28,6 +28,16 @@ import { Textarea } from '../components/ui/textarea';
import { Modal } from '../components/ui/modal';
import { Separator } from '../components/ui/separator';
import { OptionCard } from '../components/ui/option-card';
import { SectionHeader } from '../components/ui/section-header';
import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert';
import ConnectorIcon from '../connectors/ConnectorIcon';
import {
loadConnectors,
selectConnections,
selectConnectorCatalog,
selectConnectorsLoaded,
} from '../connectors/connectorsSlice';
import type { AppDispatch } from '../store';
import { ActiveState, Doc } from '../models/misc';
import { getDocs } from '../preferences/preferenceApi';
@@ -38,10 +48,12 @@ import {
setSourceDocs,
} from '../preferences/preferenceSlice';
import {
CONNECTION_INGESTORS,
IngestorDefaultConfigs,
IngestorFormSchemas,
getIngestorSchema,
IngestorOption,
UPLOAD_AND_WEB_INGESTORS,
} from '../upload/types/ingestor';
import { addUploadTask, updateUploadTask } from './uploadSlice';
@@ -65,6 +77,7 @@ function Upload({
close,
onSuccessfulUpload = () => undefined,
selectUploadedDoc = true,
initialIngestor,
}: {
receivedFile: File[];
setModalState: (state: ActiveState) => void;
@@ -79,9 +92,14 @@ function Upload({
* uploading never repoints the conversation the user left open.
*/
selectUploadedDoc?: boolean;
/** Open straight on this source type's form (Connect from a connector card). */
initialIngestor?: IngestorType;
}) {
const token = useSelector(selectToken);
const selectedDocs = useSelector(selectSelectedDocs);
const connectorCatalog = useSelector(selectConnectorCatalog);
const connections = useSelector(selectConnections);
const connectorsLoaded = useSelector(selectConnectorsLoaded);
const [files, setfiles] = useState<File[]>(receivedFile);
const [activeTab, setActiveTab] = useState<boolean>(true);
@@ -334,6 +352,7 @@ function Upload({
setSelectedFiles(selectedFileIds);
setSelectedFolders(selectedFolderIds);
}}
onFirstPickName={prefillName}
provider={ingestor.type as unknown as string}
token={token}
initialSelectedFiles={selectedFiles}
@@ -341,7 +360,9 @@ function Upload({
/>
);
case 'google_drive_picker':
return (
// Google's own picker needs the client id in the browser; without it
// the server-side file browser lists the same Drive.
return envVar('VITE_GOOGLE_CLIENT_ID') ? (
<GoogleDrivePicker
key={field.name}
onSelectionChange={(
@@ -351,8 +372,25 @@ function Upload({
setSelectedFiles(selectedFileIds);
setSelectedFolders(selectedFolderIds);
}}
onFirstPickName={prefillName}
token={token}
/>
) : (
<FilePicker
key={field.name}
onSelectionChange={(
selectedFileIds: string[],
selectedFolderIds: string[] = [],
) => {
setSelectedFiles(selectedFileIds);
setSelectedFolders(selectedFolderIds);
}}
onFirstPickName={prefillName}
provider="google_drive"
token={token}
initialSelectedFiles={selectedFiles}
initialSelectedFolders={selectedFolders}
/>
);
case 'share_point_picker':
return (
@@ -365,6 +403,7 @@ function Upload({
setSelectedFiles(selectedFileIds);
setSelectedFolders(selectedFolderIds);
}}
onFirstPickName={prefillName}
provider="share_point"
token={token}
initialSelectedFiles={selectedFiles}
@@ -382,6 +421,7 @@ function Upload({
setSelectedFiles(selectedFileIds);
setSelectedFolders(selectedFolderIds);
}}
onFirstPickName={prefillName}
provider="confluence"
token={token}
initialSelectedFiles={selectedFiles}
@@ -402,17 +442,39 @@ function Upload({
const [nameTouched, setNameTouched] = useState(false);
const { t } = useTranslation();
const dispatch = useDispatch();
const dispatch = useDispatch<AppDispatch>();
const store = useStore<RootState>();
const ingestorOptions: IngestorOption[] = IngestorFormSchemas.filter(
(schema) => (schema.validate ? schema.validate() : true),
).map((schema) => ({
label: schema.label,
value: schema.key,
icon: schema.icon,
heading: schema.heading,
}));
useEffect(() => {
if (!connectorsLoaded) dispatch(loadConnectors({ token }));
}, [connectorsLoaded, dispatch, token]);
useEffect(() => {
if (initialIngestor) handleIngestorTypeChange(initialIngestor);
// Only the type the modal opened with; later picks are the user's.
}, []);
/** The name field follows the first picked item until the user edits it. */
const prefillName = (name: string) => {
if (nameTouched || !name) return;
setIngestor((prev) => (prev.name ? prev : { ...prev, name }));
};
const connectorFor = (type: IngestorType | null) =>
type && CONNECTION_INGESTORS.includes(type)
? connectorCatalog.find((c) => c.sync_ingestor === type)
: undefined;
const selectedConnector = connectorFor(ingestor.type);
const needsSetup = !!selectedConnector && !selectedConnector.available;
const ingestorOptions: IngestorOption[] = IngestorFormSchemas.map(
(schema) => ({
label: schema.label,
value: schema.key,
icon: schema.icon,
heading: schema.heading,
}),
);
const resetUploaderState = useCallback(() => {
setIngestor({ type: null, name: '', config: {} });
@@ -923,6 +985,7 @@ function Upload({
if (!isPrescreenConfigValid(retrievalOptions)) return true;
if (!ingestor.type) return true;
if (needsSetup) return true;
const ingestorSchemaForValidation = getIngestorSchema(
ingestor.type as IngestorType,
);
@@ -1026,28 +1089,109 @@ function Upload({
}
};
const connectionTileState = (type: IngestorType) => {
const connector = connectorFor(type);
if (!connector) return undefined;
if (!connector.available)
return t('settings.connectors.status.needsAdminSetup');
const accounts = connections.filter(
(c) => c.connector_key === connector.key && c.status === 'connected',
);
if (accounts.length === 1)
return t('modals.uploadDoc.tileConnectedAs', {
account: accounts[0].account_label,
interpolation: { escapeValue: false },
});
if (accounts.length > 1)
return t('settings.connectors.status.connectedCount', {
count: accounts.length,
});
return t('settings.connectors.status.connect');
};
const renderIngestorSelection = () => {
const optionsFor = (types: IngestorType[]) =>
types
.map((type) => ingestorOptions.find((o) => o.value === type))
.filter((option): option is IngestorOption => !!option);
return (
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{ingestorOptions.map((option) => (
<OptionCard
key={option.value}
icon={
<img
src={option.icon}
alt={option.label}
className="size-6 dark:invert"
/>
}
title={t(`modals.uploadDoc.ingestors.${option.value}.label`)}
onClick={() =>
handleIngestorTypeChange(option.value as IngestorType)
}
<div className="flex w-full flex-col gap-6">
<section className="flex flex-col gap-3">
<SectionHeader
as="h3"
size="sm"
title={t('modals.uploadDoc.groupUploadWeb')}
/>
))}
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{optionsFor(UPLOAD_AND_WEB_INGESTORS).map((option) => (
<OptionCard
key={option.value}
icon={
<img
src={option.icon}
alt=""
className="size-6 dark:invert"
/>
}
title={t(`modals.uploadDoc.ingestors.${option.value}.label`)}
onClick={() => handleIngestorTypeChange(option.value)}
/>
))}
</div>
</section>
<section className="flex flex-col gap-3">
<SectionHeader
as="h3"
size="sm"
title={t('modals.uploadDoc.groupConnection')}
/>
<div className="grid w-full grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{optionsFor(CONNECTION_INGESTORS).map((option) => {
const connector = connectorFor(option.value);
return (
<OptionCard
key={option.value}
icon={
<ConnectorIcon
icon={connector?.icon ?? option.value}
className="size-6"
/>
}
title={t(`modals.uploadDoc.ingestors.${option.value}.label`)}
description={connectionTileState(option.value)}
onClick={() => handleIngestorTypeChange(option.value)}
/>
);
})}
</div>
</section>
</div>
);
};
const renderSetupNotice = () =>
selectedConnector && needsSetup ? (
<Alert variant="warning">
<CircleAlert />
<AlertTitle>
{t('settings.connectors.status.needsAdminSetup')}
</AlertTitle>
<AlertDescription>
<div className="flex flex-col gap-2">
{selectedConnector.missing_settings.length > 0 ? (
<>
<span>{t('settings.connectors.setupSettings')}</span>
<code className="font-mono text-xs wrap-anywhere">
{selectedConnector.missing_settings.join(', ')}
</code>
</>
) : (
<span>{t('settings.connectors.askAdmin')}</span>
)}
</div>
</AlertDescription>
</Alert>
) : null;
return (
<Modal
open={true}
@@ -1114,8 +1258,8 @@ function Upload({
required={true}
className="w-full"
/>
{renderFormFields()}
{ingestor.type !== 'wiki' && (
{needsSetup ? renderSetupNotice() : renderFormFields()}
{ingestor.type !== 'wiki' && !needsSetup && (
<RetrievalOptions
value={retrievalOptions}
onChange={setRetrievalOptions}
+16 -14
View File
@@ -1,4 +1,3 @@
import { envVar } from '@/env';
import CrawlerIcon from '../../assets/crawler.svg';
import FileUploadIcon from '../../assets/file_upload.svg';
import UrlIcon from '../../assets/url.svg';
@@ -63,10 +62,25 @@ export interface IngestorSchema {
label: string;
icon: string;
heading: string;
validate?: () => boolean;
fields: FormField[];
}
/** Add Source groups: what needs no account, and what comes from a connection. */
export const UPLOAD_AND_WEB_INGESTORS: IngestorType[] = [
'local_file',
'url',
'crawler',
'github',
'wiki',
];
export const CONNECTION_INGESTORS: IngestorType[] = [
'google_drive',
'share_point',
'confluence',
's3',
'reddit',
];
export const IngestorFormSchemas: IngestorSchema[] = [
{
key: 'local_file',
@@ -175,10 +189,6 @@ export const IngestorFormSchemas: IngestorSchema[] = [
label: 'Google Drive',
icon: DriveIcon,
heading: 'Upload from Google Drive',
validate: () => {
const googleClientId = envVar('VITE_GOOGLE_CLIENT_ID');
return !!googleClientId;
},
fields: [
{
name: 'files',
@@ -243,10 +253,6 @@ export const IngestorFormSchemas: IngestorSchema[] = [
label: 'Share Point',
icon: SharePoint,
heading: 'Upload from Share Point',
validate: () => {
const sharePointClientId = envVar('VITE_SHARE_POINT_CLIENT_ID');
return !!sharePointClientId;
},
fields: [
{
name: 'files',
@@ -261,10 +267,6 @@ export const IngestorFormSchemas: IngestorSchema[] = [
label: 'Confluence',
icon: ConfluenceIcon,
heading: 'Upload from Confluence',
validate: () => {
const confluenceClientId = envVar('VITE_CONFLUENCE_CLIENT_ID');
return !!confluenceClientId;
},
fields: [
{
name: 'files',