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.
This commit is contained in:
Alex committed 2026-09-11 11:28:26 +01:00
1 parent c26b8a3403
commit 25987c8947
15 files changed
+215 -72

No files matched your search

+50 -29
View File
@@ -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}
/>
)}
<AddPromptModal
+13 -7
View File
@@ -3,6 +3,7 @@ import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
@@ -55,7 +56,7 @@ import {
partitionAttachmentFiles,
} from '../constants/fileUpload';
import { UserToolType } from '../settings/types';
import { sourceItemId } from '../utils/sourceUtils';
import { sourceItemId, toSourcePickerItems } from '../utils/sourceUtils';
import { isChatToolVisible } from '../utils/toolUtils';
const generateId = (): string =>
@@ -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 = (
@@ -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 (
<div className="flex flex-col gap-3">
<a
href="/settings/sources"
<Link
to="/settings/sources"
className="text-primary inline-flex items-center gap-2 text-base font-medium"
onClick={onNavigate}
>
{t('settings.sources.goToSources')}
<img src={RedirectIcon} alt="" aria-hidden="true" className="h-3 w-3" />
</a>
</Link>
<Button
type="button"
variant="outline"
+3 -1
View File
@@ -1089,7 +1089,9 @@
"link": "Quellen-Link",
"view_more": "{{count}} weitere Quellen",
"noSourcesAvailable": "Keine Quellen verfügbar",
"selectedCount": "{{count}} Quellen ausgewählt"
"selectedCount": "{{count}} Quellen ausgewählt",
"groupOwn": "Deine Quellen",
"groupTeam": "Mit dem Team geteilt"
},
"attachments": {
"attach": "Anhängen",
+3 -1
View File
@@ -1094,7 +1094,9 @@
"link": "Source link",
"view_more": "{{count}} more sources",
"noSourcesAvailable": "No sources available",
"selectedCount": "{{count}} sources selected"
"selectedCount": "{{count}} sources selected",
"groupOwn": "Your sources",
"groupTeam": "Shared with team"
},
"attachments": {
"attach": "Attach",
+3 -1
View File
@@ -1089,7 +1089,9 @@
"link": "Enlace fuente",
"view_more": "Ver {{count}} más fuentes",
"noSourcesAvailable": "No hay fuentes disponibles",
"selectedCount": "{{count}} fuentes seleccionadas"
"selectedCount": "{{count}} fuentes seleccionadas",
"groupOwn": "Tus fuentes",
"groupTeam": "Compartidas con el equipo"
},
"attachments": {
"attach": "Adjuntar",
+3 -1
View File
@@ -1089,7 +1089,9 @@
"link": "ソースリンク",
"view_more": "さらに{{count}}個のソース",
"noSourcesAvailable": "利用可能なソースがありません",
"selectedCount": "{{count}} 件のソースを選択中"
"selectedCount": "{{count}} 件のソースを選択中",
"groupOwn": "自分のソース",
"groupTeam": "チームと共有"
},
"attachments": {
"attach": "添付",
+3 -1
View File
@@ -1089,7 +1089,9 @@
"link": "Ссылка на источник",
"view_more": "ещё {{count}} источников",
"noSourcesAvailable": "Нет доступных источников",
"selectedCount": "Выбрано источников: {{count}}"
"selectedCount": "Выбрано источников: {{count}}",
"groupOwn": "Ваши источники",
"groupTeam": "Общие с командой"
},
"attachments": {
"attach": "Прикрепить",
+3 -1
View File
@@ -1089,7 +1089,9 @@
"link": "來源連結",
"view_more": "查看更多 {{count}} 個來源",
"noSourcesAvailable": "沒有可用的來源",
"selectedCount": "已選擇 {{count}} 個來源"
"selectedCount": "已選擇 {{count}} 個來源",
"groupOwn": "我的來源",
"groupTeam": "團隊共享"
},
"attachments": {
"attach": "附件",
+3 -1
View File
@@ -1089,7 +1089,9 @@
"link": "来源链接",
"view_more": "还有{{count}}个来源",
"noSourcesAvailable": "没有可用的来源",
"selectedCount": "已选择 {{count}} 个来源"
"selectedCount": "已选择 {{count}} 个来源",
"groupOwn": "我的来源",
"groupTeam": "团队共享"
},
"attachments": {
"attach": "附件",
@@ -1,12 +1,16 @@
import { describe, expect, it } from 'vitest';
import { configureStore } from '@reduxjs/toolkit';
import { beforeEach, describe, expect, it } from 'vitest';
import type { Doc } from '../models/misc';
import type { RootState } from '../store';
import reducer, {
clearRoles,
prefListenerMiddleware,
selectIsAdmin,
selectRoles,
selectRolesResolved,
setRoles,
setSourceDocs,
} from './preferenceSlice';
const baseState = () => reducer(undefined, { type: '@@INIT' });
@@ -47,3 +51,54 @@ describe('roles selectors', () => {
expect(selectRolesResolved(stateWith(['user'], false))).toBe(false);
});
});
const sourceDoc = (overrides: Partial<Doc>): Doc => ({
name: 'doc',
date: '2026-01-01',
model: 'm',
...overrides,
});
const storeWithListener = () =>
configureStore({
reducer: { preference: reducer },
middleware: (getDefault) =>
getDefault().prepend(prefListenerMiddleware.middleware),
});
describe('setSourceDocs reconciler', () => {
beforeEach(() => {
localStorage.clear();
});
it('prunes a stored selection when the source list comes back empty', () => {
const stored = [sourceDoc({ id: 'gone', name: 'Deleted' })];
localStorage.setItem('DocsGPTRecentDocs', JSON.stringify(stored));
const store = storeWithListener();
store.dispatch(setSourceDocs([]));
expect(store.getState().preference.selectedDocs).toEqual([]);
expect(localStorage.getItem('DocsGPTRecentDocs')).toBeNull();
});
it('keeps a stored selection that is still in the list', () => {
const kept = sourceDoc({ id: 'kept', name: 'Kept' });
localStorage.setItem('DocsGPTRecentDocs', JSON.stringify([kept]));
const store = storeWithListener();
store.dispatch(setSourceDocs([kept, sourceDoc({ id: 'other' })]));
expect(store.getState().preference.selectedDocs).toEqual([kept]);
});
it('leaves the selection alone while the list has not loaded', () => {
const stored = [sourceDoc({ id: 'pending' })];
localStorage.setItem('DocsGPTRecentDocs', JSON.stringify(stored));
const store = storeWithListener();
store.dispatch(setSourceDocs(null));
expect(localStorage.getItem('DocsGPTRecentDocs')).not.toBeNull();
});
});
+6 -8
View File
@@ -214,14 +214,12 @@ prefListenerMiddleware.startListening({
effect: (_action, listenerApi) => {
const state = listenerApi.getState() as RootState;
const sourceDocs = state.preference.sourceDocs;
if (sourceDocs && sourceDocs.length > 0) {
const validatedDocs = getLocalRecentDocs(sourceDocs);
if (validatedDocs !== null) {
listenerApi.dispatch(setSelectedDocs(validatedDocs));
} else {
listenerApi.dispatch(setSelectedDocs([]));
}
}
// ``null`` means the list hasn't loaded; an empty array is a real answer
// and still has to prune a stored selection, or a deleted last source
// stays checked forever.
if (!sourceDocs) return;
const validatedDocs = getLocalRecentDocs(sourceDocs);
listenerApi.dispatch(setSelectedDocs(validatedDocs ?? []));
},
});
+47 -7
View File
@@ -61,6 +61,7 @@ function Upload({
renderTab = null,
close,
onSuccessfulUpload = () => undefined,
selectUploadedDoc = true,
}: {
receivedFile: File[];
setModalState: (state: ActiveState) => void;
@@ -69,6 +70,12 @@ function Upload({
close: () => void;
/** Fires once the upload is ingested, with the id of the source it created. */
onSuccessfulUpload?: (sourceId?: string) => void;
/**
* Whether a finished upload also becomes the chat's selected source. Callers
* that only need the new source's id (the agent builder) pass ``false`` so
* uploading never repoints the conversation the user left open.
*/
selectUploadedDoc?: boolean;
}) {
const token = useSelector(selectToken);
const selectedDocs = useSelector(selectSelectedDocs);
@@ -431,6 +438,32 @@ function Upload({
[dispatch],
);
/** Re-read the source list into the store so a new source is pickable. */
const refreshSourceDocs = useCallback(
() =>
getDocs(token).then((docs) => {
dispatch(setSourceDocs(docs));
return docs;
}),
[dispatch, token],
);
/**
* Finish an upload that never got an ingest task (the wiki source is created
* synchronously). The list still has to be refreshed, or the caller selects
* an id no picker can render.
*/
const finishUntrackedUpload = useCallback(
(sourceId?: string) => {
refreshSourceDocs()
.catch((err) => {
console.error('Post-upload source-list refresh failed:', err);
})
.finally(() => onSuccessfulUpload?.(sourceId));
},
[onSuccessfulUpload, refreshSourceDocs],
);
/**
* Wait for the source.ingest.* SSE pipeline to flip this task into a
* terminal state, then run the post-completion side effects: refresh
@@ -450,10 +483,9 @@ function Upload({
if (handled) return;
handled = true;
if (status !== 'completed') return;
getDocs(token)
refreshSourceDocs()
.then((docs) => {
dispatch(setSourceDocs(docs));
if (Array.isArray(docs)) {
if (selectUploadedDoc && Array.isArray(docs)) {
const existingDocIds = new Set(
(Array.isArray(sourceDocs) ? sourceDocs : [])
.map((doc: Doc) => doc?.id)
@@ -540,7 +572,15 @@ function Upload({
}
});
},
[dispatch, onSuccessfulUpload, selectedDocs, sourceDocs, store, token],
[
dispatch,
onSuccessfulUpload,
refreshSourceDocs,
selectedDocs,
selectUploadedDoc,
sourceDocs,
store,
],
);
const onDrop = useCallback(
@@ -630,7 +670,7 @@ function Upload({
updates: { status: 'completed', progress: 100 },
}),
);
onSuccessfulUpload?.(parsed.source_id);
finishUntrackedUpload(parsed.source_id);
}
} catch (error) {
handleTaskFailure(clientTaskId);
@@ -766,7 +806,7 @@ function Upload({
updates: { status: 'completed', progress: 100 },
}),
);
onSuccessfulUpload?.(response.source_id);
finishUntrackedUpload(response.source_id);
}
} catch (error) {
handleTaskFailure(clientTaskId);
@@ -817,7 +857,7 @@ function Upload({
},
}),
);
onSuccessfulUpload?.(data.source_id);
finishUntrackedUpload(data.source_id);
})
.catch(() => handleTaskFailure(clientTaskId));
};
+7 -1
View File
@@ -115,9 +115,15 @@ describe('serializeAgentSources', () => {
});
describe('selectedSourceIdsFromAgent', () => {
it('prefers the sources list over the single source', () => {
it('merges the single source with the sources list', () => {
expect(
selectedSourceIdsFromAgent({ source: 'x', sources: ['a', 'b'] }),
).toEqual(['x', 'a', 'b']);
});
it('keeps a source named in both fields once', () => {
expect(
selectedSourceIdsFromAgent({ source: 'a', sources: ['a', 'b'] }),
).toEqual(['a', 'b']);
});
+11 -9
View File
@@ -56,18 +56,20 @@ export function serializeAgentSources(
}
/**
* Selected source ids for a stored agent. ``sources`` wins over the legacy
* single ``source``; the legacy ``"default"`` placeholder means no source.
* Selected source ids for a stored agent: the legacy single ``source`` and the
* ``sources`` list merged, because retrieval reads both. Preferring one over
* the other would drop the omitted field on the next save. The legacy
* ``"default"`` placeholder means no source.
*/
export function selectedSourceIdsFromAgent(agent: {
source?: string;
sources?: string[];
}): string[] {
const ids =
agent.sources && agent.sources.length > 0
? agent.sources
: agent.source
? [agent.source]
: [];
return ids.filter((id) => Boolean(id) && id !== 'default');
const ids = [
...(agent.source ? [agent.source] : []),
...(agent.sources ?? []),
];
return Array.from(new Set(ids)).filter(
(id) => Boolean(id) && id !== 'default',
);
}