diff --git a/web/features/new-rag/sources/__tests__/page.spec.tsx b/web/features/new-rag/sources/__tests__/page.spec.tsx
index 4491142e568..a97fed52dca 100644
--- a/web/features/new-rag/sources/__tests__/page.spec.tsx
+++ b/web/features/new-rag/sources/__tests__/page.spec.tsx
@@ -1,9 +1,10 @@
+import type { Getter } from 'jotai'
import type { Source, SourceSyncPolicy, SourceWorkflowRun } from '../source-models'
import type { DataSourceItem } from '@/app/components/workflow/block-selector/types'
import { act, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import datasetTranslations from '@/i18n/en-US/dataset.json'
-import { renderWithNuqs as render } from '@/test/nuqs-testing'
+import { renderWithNuqs } from '@/test/nuqs-testing'
import { SourcesPage } from '../page'
vi.mock('../../components/knowledge-model-readiness-banner', () => ({
@@ -115,6 +116,9 @@ const sourcesQuery = vi.hoisted(() => ({
isPending: false,
refetch: vi.fn(),
}))
+const jotaiQueryMocks = vi.hoisted(() => ({
+ bump: undefined as undefined | (() => void),
+}))
const connectionsQuery = vi.hoisted(() => ({
data: undefined as
| {
@@ -205,6 +209,33 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
useQueryClient: () => ({ invalidateQueries: invalidateQueriesMock }),
}
})
+vi.mock('jotai-tanstack-query', async (importOriginal) => {
+ const original = await importOriginal()
+ const { atom, getDefaultStore } = await import('jotai/vanilla')
+ const revisionAtom = atom(0)
+ jotaiQueryMocks.bump = () =>
+ getDefaultStore().set(revisionAtom, (revision: number) => revision + 1)
+
+ return {
+ ...original,
+ atomWithInfiniteQuery: (getOptions: (get: Getter) => unknown) =>
+ atom((get) => {
+ get(revisionAtom)
+ getOptions(get)
+ return {
+ ...sourcesQuery,
+ data: sourcesQuery.data
+ ? {
+ pages: sourcesQuery.data.pages.map((page) => ({
+ data: page.items.map(sourceApiResponse),
+ next_cursor: page.nextCursor ?? null,
+ })),
+ }
+ : undefined,
+ }
+ }),
+ }
+})
vi.mock('@/next/navigation', () => ({
usePathname: () => '/datasets/new/space-1/sources',
useRouter: () => routerMock,
@@ -280,6 +311,19 @@ vi.mock('@/service/client', () => ({
},
}))
+function render(...args: Parameters) {
+ jotaiQueryMocks.bump?.()
+ const rendered = renderWithNuqs(...args)
+ const rerender = rendered.rerender
+ return {
+ ...rendered,
+ rerender: (ui: Parameters[0]) => {
+ jotaiQueryMocks.bump?.()
+ rerender(ui)
+ },
+ }
+}
+
const source = (overrides: Partial): Source => ({
createdAt: '2026-07-20T10:00:00Z',
id: 'source-1',
diff --git a/web/features/new-rag/sources/__tests__/state-boundary.spec.tsx b/web/features/new-rag/sources/__tests__/state-boundary.spec.tsx
new file mode 100644
index 00000000000..a3ddfbf757d
--- /dev/null
+++ b/web/features/new-rag/sources/__tests__/state-boundary.spec.tsx
@@ -0,0 +1,147 @@
+import { act, render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { atom, createStore, Provider, useAtomValue, useSetAtom } from 'jotai'
+import {
+ removedSourceIdsAtom,
+ removeSourceFromListAtom,
+ sourcesAwaitedOperationIdAtom,
+ sourcesFilterAtom,
+ sourcesKnowledgeSpaceIdAtom,
+ sourcesSearchAtom,
+ sourcesSortAtom,
+} from '../state'
+import { SourcesStateBoundary } from '../state-boundary'
+
+const parentValueAtom = atom('missing')
+
+function SourcesInputs({ label }: { label: string }) {
+ const knowledgeSpaceId = useAtomValue(sourcesKnowledgeSpaceIdAtom)
+ const filter = useAtomValue(sourcesFilterAtom)
+ const search = useAtomValue(sourcesSearchAtom)
+ const sort = useAtomValue(sourcesSortAtom)
+ const awaitedOperationId = useAtomValue(sourcesAwaitedOperationIdAtom)
+ const parentValue = useAtomValue(parentValueAtom)
+ return (
+ {`${label}:${knowledgeSpaceId}:${filter}:${search}:${sort}:${awaitedOperationId}:${parentValue}`}
+ )
+}
+
+function RemovedSourcesSession() {
+ const removedSourceIds = useAtomValue(removedSourceIdsAtom)
+ const removeSource = useSetAtom(removeSourceFromListAtom)
+ return (
+
+ )
+}
+
+describe('SourcesStateBoundary', () => {
+ it('isolates route inputs between sibling instances while observing the parent store', () => {
+ const store = createStore()
+ store.set(parentValueAtom, 'parent-visible')
+
+ render(
+
+
+
+
+
+
+
+ ,
+ )
+
+ expect(
+ screen.getByText('first:space-a:active:alpha:name-asc:operation-a:parent-visible'),
+ ).toBeInTheDocument()
+ expect(
+ screen.getByText('second:space-b:error:beta:name-desc:null:parent-visible'),
+ ).toBeInTheDocument()
+ })
+
+ it('follows authoritative URL inputs without resetting the space session', async () => {
+ const user = userEvent.setup()
+ const rendered = render(
+
+
+
+ ,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'none' }))
+ expect(screen.getByRole('button', { name: 'source-1' })).toBeInTheDocument()
+
+ act(() =>
+ rendered.rerender(
+
+
+
+ ,
+ ),
+ )
+
+ expect(
+ screen.getByText('sources:space-a:syncing:docs:name-asc:operation-2:missing'),
+ ).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'source-1' })).toBeInTheDocument()
+ })
+
+ it('resets workflow primitives when the knowledge space identity changes', async () => {
+ const user = userEvent.setup()
+ const rendered = render(
+
+
+ ,
+ )
+
+ await user.click(screen.getByRole('button', { name: 'none' }))
+ expect(screen.getByRole('button', { name: 'source-1' })).toBeInTheDocument()
+
+ act(() =>
+ rendered.rerender(
+
+
+ ,
+ ),
+ )
+
+ expect(screen.getByRole('button', { name: 'none' })).toBeInTheDocument()
+ })
+})
diff --git a/web/features/new-rag/sources/page.tsx b/web/features/new-rag/sources/page.tsx
index 453e2ac482a..2f9850160a4 100644
--- a/web/features/new-rag/sources/page.tsx
+++ b/web/features/new-rag/sources/page.tsx
@@ -2,6 +2,7 @@
import type { SourceFilter } from './source-list-query-state'
import type { Source } from './source-models'
+import type { SourceSort } from './state'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { Checkbox } from '@langgenius/dify-ui/checkbox'
import { cn } from '@langgenius/dify-ui/cn'
@@ -14,112 +15,80 @@ import {
SelectLabel,
SelectTrigger,
} from '@langgenius/dify-ui/select'
-import { useInfiniteQuery } from '@tanstack/react-query'
+import { useAtomValue, useSetAtom } from 'jotai'
import { useQueryState } from 'nuqs'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { SearchInput } from '@/app/components/base/search-input'
import Link from '@/next/link'
-import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
-import { consoleQuery } from '@/service/client'
+import { useSearchParams } from '@/next/navigation'
import { KnowledgeModelReadinessBanner } from '../components/knowledge-model-readiness-banner'
import { KnowledgeModelSetupDialog } from '../components/knowledge-model-setup-dialog'
import { newKnowledgeAddSourcePath } from '../routes'
import { useKnowledgeSpacePermission } from '../space/context'
import { useKnowledgeModelSetupGuard } from '../use-knowledge-model-setup-guard'
import { SourcesEmpty } from './empty'
+import { SourcesRuntimeController } from './runtime-controller'
import { SourceRow } from './source-list-item'
import { sourceTableGridClass } from './source-list-layout'
import { sourceFilterParser, sourceSearchParser, sourceSortParser } from './source-list-query-state'
import {
- initialSourcePollingPhase,
- isInitialSourceForOperation,
- shouldHidePreviewSource,
- sourceDisplayStatus,
- sourceFromApi,
- sourceNeedsPolling,
- sourceStatusWithSyncWorkflow,
- sourceWorkflowIsActive,
-} from './source-models'
-
-const PAGE_SIZE = 200
-const MAX_AUTO_CURSOR_PAGES = 5
-const AWAIT_INITIAL_SOURCE_POLL_INTERVAL = 2000
-const SOURCE_POLL_INTERVAL = 3000
-const INITIAL_SOURCE_POLL_TIMEOUT = 10 * 60 * 1000
-
-function latestSourceWorkflow(
- sourceWorkflow?: Source['syncWorkflow'],
- sourceOverrideWorkflow?: Source['syncWorkflow'],
-) {
- if (!sourceWorkflow || !sourceOverrideWorkflow) return sourceWorkflow ?? sourceOverrideWorkflow
- if (sourceWorkflow.id === sourceOverrideWorkflow.id) {
- if (sourceWorkflow.executionAttempts !== sourceOverrideWorkflow.executionAttempts)
- return sourceWorkflow.executionAttempts > sourceOverrideWorkflow.executionAttempts
- ? sourceWorkflow
- : sourceOverrideWorkflow
-
- return sourceWorkflow.updatedAt >= sourceOverrideWorkflow.updatedAt
- ? sourceWorkflow
- : sourceOverrideWorkflow
- }
- const sourceWorkflowIsRunning = sourceWorkflowIsActive(sourceWorkflow)
- const sourceOverrideWorkflowIsRunning = sourceWorkflowIsActive(sourceOverrideWorkflow)
- // The server snapshot ranks active runs first, so an active server run remains authoritative
- // even when it is an older run being retried. A local active override still has to be newer
- // than a terminal server run, otherwise it could remain stuck after a later run completes.
- if (sourceWorkflowIsRunning && !sourceOverrideWorkflowIsRunning) return sourceWorkflow
- const createdAtComparison = sourceWorkflow.createdAt.localeCompare(
- sourceOverrideWorkflow.createdAt,
- )
- if (createdAtComparison !== 0)
- return createdAtComparison > 0 ? sourceWorkflow : sourceOverrideWorkflow
- const updatedAtComparison = sourceWorkflow.updatedAt.localeCompare(
- sourceOverrideWorkflow.updatedAt,
- )
- if (updatedAtComparison !== 0)
- return updatedAtComparison > 0 ? sourceWorkflow : sourceOverrideWorkflow
- return sourceWorkflow.id > sourceOverrideWorkflow.id ? sourceWorkflow : sourceOverrideWorkflow
-}
-
-function getCurrentSource(source: Source, sourceOverride?: Source) {
- if (!sourceOverride || sourceOverride.id !== source.id) return source
- const sourceVersion = source.version ?? -1
- const overrideVersion = sourceOverride.version ?? -1
- if (sourceVersion > overrideVersion) return source
- const overrideHasNewerSource =
- sourceVersion < overrideVersion || source.updatedAt < sourceOverride.updatedAt
- const sourceHasNewerSource =
- sourceVersion === overrideVersion && source.updatedAt > sourceOverride.updatedAt
- if (sourceHasNewerSource) return source
- const syncWorkflow = overrideHasNewerSource
- ? sourceOverride.syncWorkflow
- : latestSourceWorkflow(source.syncWorkflow, sourceOverride.syncWorkflow)
- if (
- !overrideHasNewerSource &&
- source.syncWorkflow &&
- source.syncWorkflow.id !== sourceOverride.syncWorkflow?.id &&
- syncWorkflow === source.syncWorkflow
- )
- return source
- return {
- ...sourceOverride,
- lastSyncedAt: source.lastSyncedAt ?? sourceOverride.lastSyncedAt,
- status: sourceStatusWithSyncWorkflow(sourceOverride.status, syncWorkflow),
- syncWorkflow,
- syncPolicy: overrideHasNewerSource
- ? (sourceOverride.syncPolicy ?? source.syncPolicy)
- : (source.syncPolicy ?? sourceOverride.syncPolicy),
- }
-}
+ completingFilteredResultsAtom,
+ fetchNextSourcePageAtom,
+ filteredSourcesAtom,
+ refreshSourcesAtom,
+ sourcesFilterAtom,
+ sourcesKnowledgeSpaceIdAtom,
+ sourcesPollingPhaseAtom,
+ sourcesQueryErrorAtom,
+ sourcesQueryFetchingNextPageAtom,
+ sourcesQueryFetchNextPageErrorAtom,
+ sourcesQueryHasDataAtom,
+ sourcesQueryHasNextPageAtom,
+ sourcesQueryPendingAtom,
+ sourcesSearchAtom,
+ sourcesSortAtom,
+ visibleSourcesAtom,
+} from './state'
+import { SourcesStateBoundary } from './state-boundary'
export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) {
+ const searchParams = useSearchParams()
+ const [filter, setFilter] = useQueryState('status', sourceFilterParser)
+ const [search, setSearch] = useQueryState('query', sourceSearchParser)
+ const [sort, setSort] = useQueryState('sort', sourceSortParser)
+ const awaitedOperationId = searchParams.get('awaitInitialSource')?.trim() || null
+
+ return (
+
+ void setFilter(value)}
+ onSearchChange={(value) => void setSearch(value)}
+ onSortChange={(value) => void setSort(value)}
+ />
+
+ )
+}
+
+function SourcesPageContent({
+ onFilterChange,
+ onSearchChange,
+ onSortChange,
+}: {
+ onFilterChange: (value: SourceFilter) => void
+ onSearchChange: (value: string) => void
+ onSortChange: (value: SourceSort) => void
+}) {
const { t } = useTranslation('dataset')
const { t: tCommon } = useTranslation('common')
- const pathname = usePathname()
- const router = useRouter()
- const searchParams = useSearchParams()
+ const knowledgeSpaceId = useAtomValue(sourcesKnowledgeSpaceIdAtom)
const {
configureModelSetup,
ensureModelReady,
@@ -134,174 +103,25 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
[ensureModelReady],
)
const canManageSources = useKnowledgeSpacePermission('knowledge_space_document_write')
- const [filter, setFilter] = useQueryState('status', sourceFilterParser)
- const [search, setSearch] = useQueryState('query', sourceSearchParser)
- const [sort, setSort] = useQueryState('sort', sourceSortParser)
- const [selectedSourceIds, setSelectedSourceIds] = useState>(() => new Set())
- const [sourceOverrides, setSourceOverrides] = useState>({})
- const [removedSourceIds, setRemovedSourceIds] = useState>(() => new Set())
- const [initialSourcePollingTimedOut, setInitialSourcePollingTimedOut] = useState(false)
- const initialSourcePollingTimeoutRef = useRef | undefined>(
- undefined,
- )
- const normalizedAwaitedOperationId = searchParams.get('awaitInitialSource')?.trim() || null
- const sourcesQuery = useInfiniteQuery(
- consoleQuery.knowledgeFs.spaces.byControlSpaceId.sources.get.infiniteOptions({
- input: (pageParam) => ({
- params: { control_space_id: knowledgeSpaceId },
- query: {
- limit: PAGE_SIZE,
- ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}),
- },
- }),
- getNextPageParam: (lastPage) => lastPage.next_cursor,
- initialPageParam: null as string | null,
- refetchInterval: (query) => {
- const currentSources =
- query.state.data?.pages.flatMap((page) =>
- page.data
- .filter((source) => !removedSourceIds.has(source.id))
- .map((source) => getCurrentSource(sourceFromApi(source), sourceOverrides[source.id])),
- ) ?? []
- const phase = initialSourcePollingPhase(
- currentSources,
- normalizedAwaitedOperationId,
- initialSourcePollingTimedOut,
- )
- if (phase === 'awaiting') return AWAIT_INITIAL_SOURCE_POLL_INTERVAL
-
- return currentSources.some(
- (source) =>
- sourceNeedsPolling(source) &&
- (!initialSourcePollingTimedOut || sourceDisplayStatus(source) !== 'initializing'),
- )
- ? SOURCE_POLL_INTERVAL
- : false
- },
- }),
- )
- const remoteSources = sourcesQuery.data?.pages.flatMap((page) =>
- page.data.map((source) => sourceFromApi(source)),
- )
- const currentSources = useMemo(
- () =>
- (remoteSources ?? [])
- .filter((source) => !removedSourceIds.has(source.id))
- .map((source) => getCurrentSource(source, sourceOverrides[source.id])),
- [remoteSources, removedSourceIds, sourceOverrides],
- )
- const pollingPhase = initialSourcePollingPhase(
- currentSources,
- normalizedAwaitedOperationId,
- initialSourcePollingTimedOut,
- )
- const initialSourcePollingActive = pollingPhase === 'awaiting' || pollingPhase === 'initializing'
+ const filter = useAtomValue(sourcesFilterAtom)
+ const search = useAtomValue(sourcesSearchAtom)
+ const sort = useAtomValue(sourcesSortAtom)
+ const pollingPhase = useAtomValue(sourcesPollingPhaseAtom)
const waitingForInitialSource = pollingPhase === 'awaiting'
- const sources = useMemo(
- () =>
- currentSources
- .filter((source) => !shouldHidePreviewSource(source))
- .sort(
- (left, right) =>
- right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id),
- ),
- [currentSources],
- )
- const filteredSources = useMemo(() => {
- const normalizedSearch = search.trim().toLocaleLowerCase()
- const nextSources = (sources ?? []).filter((source) => {
- if (filter !== 'all' && sourceDisplayStatus(source) !== filter) return false
- if (!normalizedSearch) return true
- return `${source.name} ${source.uri}`.toLocaleLowerCase().includes(normalizedSearch)
- })
- if (!sort) return nextSources
- return [...nextSources].sort((left, right) => {
- const result = left.name.localeCompare(right.name)
- return sort === 'name-asc' ? result : -result
- })
- }, [filter, search, sort, sources])
- const localTransformActive = filter !== 'all' || Boolean(search.trim()) || Boolean(sort)
- const loadedSourcePageCount = sourcesQuery.data?.pages.length ?? 0
- const canAutoLoadNextPage = loadedSourcePageCount < MAX_AUTO_CURSOR_PAGES
- const canAutoCompleteFilteredResults = localTransformActive && canAutoLoadNextPage
- const latestSourcePage = sourcesQuery.data?.pages.at(-1)
- const needsVisibleSource =
- latestSourcePage !== undefined &&
- latestSourcePage.data.some((source) =>
- shouldHidePreviewSource(getCurrentSource(sourceFromApi(source), sourceOverrides[source.id])),
- ) &&
- !latestSourcePage.data.some((source) => {
- if (removedSourceIds.has(source.id)) return false
- return !shouldHidePreviewSource(
- getCurrentSource(sourceFromApi(source), sourceOverrides[source.id]),
- )
- })
- const completingFilteredResults =
- (canAutoCompleteFilteredResults || (needsVisibleSource && canAutoLoadNextPage)) &&
- !sourcesQuery.isFetchNextPageError &&
- (sourcesQuery.hasNextPage || sourcesQuery.isFetchingNextPage)
- const allFilteredSourcesSelected =
- filteredSources.length > 0 &&
- filteredSources.every((source) => selectedSourceIds.has(source.id))
- const someFilteredSourcesSelected = filteredSources.some((source) =>
- selectedSourceIds.has(source.id),
- )
- const {
- fetchNextPage: fetchNextSourcePage,
- hasNextPage: hasNextSourcePage,
- isFetchingNextPage: isFetchingNextSourcePage,
- } = sourcesQuery
-
- useEffect(() => {
- if (
- normalizedAwaitedOperationId &&
- currentSources.some((source) =>
- isInitialSourceForOperation(source, normalizedAwaitedOperationId),
- )
- ) {
- const nextSearchParams = new URLSearchParams(searchParams)
- nextSearchParams.delete('awaitInitialSource')
- const queryString = nextSearchParams.toString()
- router.replace(queryString ? `${pathname}?${queryString}` : pathname, { scroll: false })
- }
- }, [currentSources, normalizedAwaitedOperationId, pathname, router, searchParams])
-
- useEffect(() => {
- if (!initialSourcePollingActive) {
- globalThis.clearTimeout(initialSourcePollingTimeoutRef.current)
- initialSourcePollingTimeoutRef.current = undefined
- return
- }
- if (initialSourcePollingTimeoutRef.current) return
-
- initialSourcePollingTimeoutRef.current = globalThis.setTimeout(() => {
- initialSourcePollingTimeoutRef.current = undefined
- setInitialSourcePollingTimedOut(true)
- }, INITIAL_SOURCE_POLL_TIMEOUT)
- }, [initialSourcePollingActive])
-
- useEffect(() => () => globalThis.clearTimeout(initialSourcePollingTimeoutRef.current), [])
-
- useEffect(() => {
- if (
- (canAutoCompleteFilteredResults || (needsVisibleSource && canAutoLoadNextPage)) &&
- hasNextSourcePage &&
- !isFetchingNextSourcePage &&
- !sourcesQuery.isFetchNextPageError
- )
- void fetchNextSourcePage()
- }, [
- canAutoCompleteFilteredResults,
- canAutoLoadNextPage,
- fetchNextSourcePage,
- hasNextSourcePage,
- isFetchingNextSourcePage,
- needsVisibleSource,
- sourcesQuery.isFetchNextPageError,
- ])
-
+ const sources = useAtomValue(visibleSourcesAtom)
+ const filteredSources = useAtomValue(filteredSourcesAtom)
+ const completingFilteredResults = useAtomValue(completingFilteredResultsAtom)
+ const sourcesQueryPending = useAtomValue(sourcesQueryPendingAtom)
+ const sourcesQueryError = useAtomValue(sourcesQueryErrorAtom)
+ const sourcesQueryHasData = useAtomValue(sourcesQueryHasDataAtom)
+ const sourcesQueryHasNextPage = useAtomValue(sourcesQueryHasNextPageAtom)
+ const sourcesQueryFetchNextPageError = useAtomValue(sourcesQueryFetchNextPageErrorAtom)
+ const sourcesQueryFetchingNextPage = useAtomValue(sourcesQueryFetchingNextPageAtom)
+ const refreshSources = useSetAtom(refreshSourcesAtom)
+ const fetchNextSourcePage = useSetAtom(fetchNextSourcePageAtom)
return (
)}
- {sourcesQuery.isPending ? (
+ {sourcesQueryPending ? (
- ) : sourcesQuery.error && !sourcesQuery.data ? (
+ ) : sourcesQueryError && !sourcesQueryHasData ? (
@@ -348,7 +168,7 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
{t(($) => $['newKnowledge.sourcesErrorDescription'])}
-
@@ -359,7 +179,7 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })
{t(($) => $['newKnowledge.awaitingInitialSource'])}
- ) : !sources?.length && !sourcesQuery.hasNextPage ? (
+ ) : !sources?.length && !sourcesQueryHasNextPage ? (
) : (
<>
@@ -367,7 +187,7 @@ export function SourcesPage({ knowledgeSpaceId }: { knowledgeSpaceId: string })