From 25987c8947b91eacab38ab17609e4fccc8505038 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 11 Sep 2026 11:28:26 +0100 Subject: [PATCH] fix(agents): correct source selection in the agent form, chat and uploads selectedSourceIdsFromAgent preferred the extra sources over the legacy single source instead of merging them. Since every save writes both fields, opening an agent that held both and saving -- even just a rename -- silently detached the primary source. Retrieval reads both, so merge them. The edit form also reported unsaved changes the moment it loaded: the snapshot taken after the fetch defaulted the retriever to "classic" and left the default model empty, while the effects that run straight after rewrite both. Normalise the snapshot through the same serializer and rules the effects apply. A source attached to a team-shared agent that the caller cannot list -- the owner's private source -- rendered no picker row, so it showed as attached with nothing to switch it off. Append rows for selected ids missing from the list, and memoise the label lookup the trigger and the picker now share. Uploading from the agent builder repointed the open conversation onto the new source and persisted it, because the upload modal always dispatched the chat selection. Gate that behind a prop the builder opts out of. The wiki source is created synchronously and never refreshed the source list, so the builder selected an id the picker could not render and the trigger read "External KB"; refresh on every completion path that skips ingest tracking. Removing the synthetic "Default" source made an empty source list reachable, which the stored-selection reconciler skipped, leaving a deleted source checked forever with no way to clear it. Treat an empty list as a real answer and only skip when the list has not loaded. Finally, finish the picker extraction: the chat input still hand-rolled the item mapping, so team users saw the same sources grouped in the agent builder and flat in chat. Point it at the shared helper, with its own locale keys, and make the sources link a router navigation instead of a full page reload. --- frontend/src/agents/NewAgent.tsx | 79 ++++++++++++------- frontend/src/components/MessageInput.tsx | 20 +++-- .../src/components/SourcesPopoverFooter.tsx | 7 +- frontend/src/locale/de.json | 4 +- frontend/src/locale/en.json | 4 +- frontend/src/locale/es.json | 4 +- frontend/src/locale/jp.json | 4 +- frontend/src/locale/ru.json | 4 +- frontend/src/locale/zh-TW.json | 4 +- frontend/src/locale/zh.json | 4 +- .../src/preferences/preferenceSlice.test.ts | 57 ++++++++++++- frontend/src/preferences/preferenceSlice.ts | 14 ++-- frontend/src/upload/Upload.tsx | 54 +++++++++++-- frontend/src/utils/sourceUtils.test.ts | 8 +- frontend/src/utils/sourceUtils.ts | 20 ++--- 15 files changed, 215 insertions(+), 72 deletions(-) diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index 3f588a9e..72238d7e 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -235,32 +235,45 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { // source list; fall back to the owner-resolved name embedded in the agent // payload (source_details) so a team member viewing a shared agent sees the // source name instead of "External KB"; only then show the generic label. - const resolveSourceLabel = (id: string): string => { - const matchedDoc = sourceDocs?.find( - (source) => sourceItemId(source) === id, - ); - if (matchedDoc?.name) return matchedDoc.name; - const detail = agent.source_details?.find((d) => d.id === id); - if (detail?.name) return detail.name; - return t('agents.form.externalKb'); - }; - - const sourceItems = useMemo( - () => - toSourcePickerItems( - sourceDocs, - { - own: t('agents.form.sourcePopup.groupOwn'), - team: t('agents.form.sourcePopup.groupTeam'), - }, - SourceIcon, - ), - [sourceDocs, t], + const resolveSourceLabel = useCallback( + (id: string): string => { + const matchedDoc = sourceDocs?.find( + (source) => sourceItemId(source) === id, + ); + if (matchedDoc?.name) return matchedDoc.name; + const detail = agent.source_details?.find((d) => d.id === id); + if (detail?.name) return detail.name; + return t('agents.form.externalKb'); + }, + [agent.source_details, sourceDocs, t], ); - const selectedSourceNames = Array.from(selectedSourceIds) - .map((id) => resolveSourceLabel(id)) - .filter(Boolean); + const sourceItems = useMemo(() => { + const items = toSourcePickerItems( + sourceDocs, + { + own: t('agents.form.sourcePopup.groupOwn'), + team: t('agents.form.sourcePopup.groupTeam'), + }, + SourceIcon, + ); + // An attached source the caller can't list — an owner's private source on + // a team-shared agent — still needs a row of its own, or it reads as + // selected with nothing to switch it off. + const listed = new Set(items.map((item) => item.id)); + const unlisted = Array.from(selectedSourceIds) + .filter((id) => !listed.has(id)) + .map((id) => ({ id, label: resolveSourceLabel(id), icon: SourceIcon })); + return [...items, ...unlisted]; + }, [resolveSourceLabel, selectedSourceIds, sourceDocs, t]); + + const selectedSourceNames = useMemo( + () => + Array.from(selectedSourceIds) + .map((id) => resolveSourceLabel(id)) + .filter(Boolean), + [resolveSourceLabel, selectedSourceIds], + ); const sourceTriggerLabel = selectedSourceIds.size === 0 ? t('agents.form.placeholders.selectSources') @@ -667,7 +680,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { } const data = await response.json(); - setSelectedSourceIds(new Set(selectedSourceIdsFromAgent(data))); + const agentSourceIds = selectedSourceIdsFromAgent(data); + setSelectedSourceIds(new Set(agentSourceIds)); if (data.tool_details) setSelectedTools(data.tool_details); if (data.status === 'draft') setEffectiveMode('draft'); @@ -679,16 +693,22 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { // Backfill required fields so older agents (created before // agent_type / prompt_id / models existed) don't fail // ``isPublishable()`` and leave Save permanently disabled. + // Normalise exactly as the source and model effects below will, or the + // form compares unequal to this snapshot and reports unsaved changes + // before the user has touched anything. + const agentModels: string[] = data.models || []; const normalized = { ...data, agent_type: data.agent_type || 'classic', prompt_id: data.prompt_id || 'default', - retriever: data.retriever || 'classic', + retriever: agentSourceIds.length === 0 ? 'classic' : '', chunks: data.chunks || '2', tools: data.tools || [], - sources: data.sources || [], - models: data.models || [], - default_model_id: data.default_model_id || '', + ...serializeAgentSources(agentSourceIds, sourceDocs), + models: agentModels, + default_model_id: agentModels.includes(data.default_model_id || '') + ? data.default_model_id + : agentModels[0] || '', config: data.config || {}, }; setAgent(normalized); @@ -1468,6 +1488,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { renderTab={null} close={() => setUploadModalState('INACTIVE')} onSuccessfulUpload={handleUploadedSource} + selectUploadedDoc={false} /> )} @@ -1460,12 +1461,17 @@ export default function MessageInput({ void _docs; }; - const sourceItems: MultiSelectPopoverItem[] = (sourceDocs || []).map( - (doc) => ({ - id: sourceItemId(doc), - label: doc.name, - icon: SourceIcon, - }), + const sourceItems: MultiSelectPopoverItem[] = useMemo( + () => + toSourcePickerItems( + sourceDocs, + { + own: t('conversation.sources.groupOwn'), + team: t('conversation.sources.groupTeam'), + }, + SourceIcon, + ), + [sourceDocs, t], ); const selectedSourceIds = ( diff --git a/frontend/src/components/SourcesPopoverFooter.tsx b/frontend/src/components/SourcesPopoverFooter.tsx index e918e718..056d47fc 100644 --- a/frontend/src/components/SourcesPopoverFooter.tsx +++ b/frontend/src/components/SourcesPopoverFooter.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; import RedirectIcon from '../assets/redirect.svg'; import { Button } from './ui/button'; @@ -18,14 +19,14 @@ export default function SourcesPopoverFooter({ return (
- {t('settings.sources.goToSources')} - +