'use client' import type { DocumentProcessingTask } from '@dify/contracts/knowledge-fs/types.gen' import type { ProcessingTaskEvent, ProcessingTaskProgressEvent, } from './services/processing-task-events' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { toast } from '@langgenius/dify-ui/toast' import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useAtomValue, useSetAtom } from 'jotai' import { debounce, parseAsString, parseAsStringLiteral, useQueryState } from 'nuqs' import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { datasetDefaultPermissionKeysAtom, refreshWorkspacePermissionKeysAfterMutationDenialAtom, retryWorkspacePermissionKeysAtom, workspacePermissionKeysErrorAtom, workspacePermissionKeysFetchingAtom, workspacePermissionKeysLoadingAtom, } from '@/context/permission-state' import { consoleClient, consoleQuery } from '@/service/client' import { DatasetACLPermission, hasPermission } from '@/utils/permission' import { useAuxiliaryTaskReadGuard } from './auxiliary-task-read-guard' import { DocumentBulkActions, DocumentsEmpty, DocumentsList } from './document-list' import { ACTIVE_TASK_STATES, documentDisplayStatus, newestTaskByDocument, sourceName, taskIsActive, taskNeedsAttention, taskVersionIsAfter, } from './document-model' import { DOCUMENT_UPLOAD_ACCEPT, documentUploadIssue } from './document-upload-policy' import { ProcessingTasksDrawer } from './processing-tasks-drawer' import { newKnowledgeDocumentDetailPath } from './routes' import { TaskEventObserver } from './task-event-observer' import { createTaskProgressStore } from './task-progress-store' import { useQueryDataUpdateCount } from './use-query-data-update-count' const DOCUMENT_PAGE_SIZE = 50 const TASK_PAGE_SIZE = 100 const MAX_TASK_EVENT_STREAMS = 6 const MAX_AUTO_CURSOR_PAGES = 20 const FAILED_TASK_POLL_REQUEST_TIMEOUT = 3000 const TERMINAL_RECONCILIATION_REQUEST_TIMEOUT = 3000 const BLOCKED_ACTIVE_TASK_REFRESH_INTERVAL = 5000 const MAX_BLOCKED_ACTIVE_TASK_REFRESH_INTERVAL = 30000 const documentFilterParser = parseAsStringLiteral([ 'all', 'ready', 'queued', 'processing', 'failed', 'disabled', ] as const) .withDefault('all') .withOptions({ history: 'push' }) const documentSearchParser = parseAsString.withDefault('').withOptions({ limitUrlUpdates: debounce(300), }) const uploadExclusionReasonKey = { batch_byte_limit_exceeded: 'batchLimit', document_not_found: 'target', file_count_limit_exceeded: 'countLimit', file_too_large: 'fileSize', invalid_file: 'fileType', invalid_target: 'target', processing_failed: 'processing', quota_exceeded: 'quota', revision_conflict: 'target', unsupported_mime_type: 'fileType', } as const type UploadExclusionReasonKey = (typeof uploadExclusionReasonKey)[keyof typeof uploadExclusionReasonKey] type TerminalTaskPin = { observedAt: string taskListGeneration: number } type TrustedActiveOverride = { taskListGeneration: number updatedAt: string } type AuxiliaryTaskReadDenial = { taskListGeneration: number taskVersion: string } const DOCUMENT_PERMISSION_DENIED = 1 const TASK_PERMISSION_DENIED = 2 const SOURCE_PERMISSION_DENIED = 4 function recoveryQueryMaskForPermissionDenials(permissionDenialMask: number) { let recoveryQueryMask = 0 if (permissionDenialMask & DOCUMENT_PERMISSION_DENIED) recoveryQueryMask |= TASK_PERMISSION_DENIED | SOURCE_PERMISSION_DENIED if (permissionDenialMask & TASK_PERMISSION_DENIED) recoveryQueryMask |= SOURCE_PERMISSION_DENIED if (permissionDenialMask & SOURCE_PERMISSION_DENIED) recoveryQueryMask |= TASK_PERMISSION_DENIED return recoveryQueryMask & ~permissionDenialMask } function responseStatus(error: unknown): number | undefined { if (error instanceof Response) return error.status if (error && typeof error === 'object' && 'status' in error) return typeof error.status === 'number' ? error.status : undefined if (error && typeof error === 'object' && 'data' in error) { const data = error.data if (data && typeof data === 'object' && 'status' in data) return typeof data.status === 'number' ? data.status : undefined } } function taskSnapshotErrorIsTransient(error: unknown) { const status = responseStatus(error) return status === undefined || status === 408 || status === 429 || status >= 500 } function queryKeyMatchesKnowledgeSpace(queryKey: readonly unknown[], knowledgeSpaceId: string) { const state = queryKey[1] if (!state || typeof state !== 'object' || !('input' in state)) return false const input = state.input if (!input || typeof input !== 'object' || !('params' in input)) return false const params = input.params return Boolean( params && typeof params === 'object' && 'id' in params && params.id === knowledgeSpaceId, ) } function normalizedTaskSnapshot(task: DocumentProcessingTask): DocumentProcessingTask { return { ...task, errorCode: task.errorCode, errorMessage: task.errorMessage, } } export function DocumentsPage({ knowledgeSpaceId }: { knowledgeSpaceId: string }) { const { t } = useTranslation('dataset') const { t: tCommon } = useTranslation('common') const queryClient = useQueryClient() const datasetDefaultPermissionKeys = useAtomValue(datasetDefaultPermissionKeysAtom) const workspacePermissionKeysLoading = useAtomValue(workspacePermissionKeysLoadingAtom) const workspacePermissionKeysFetching = useAtomValue(workspacePermissionKeysFetchingAtom) const workspacePermissionKeysError = useAtomValue(workspacePermissionKeysErrorAtom) const retryWorkspacePermissionKeys = useSetAtom(retryWorkspacePermissionKeysAtom) const refreshWorkspacePermissionKeysAfterMutationDenial = useSetAtom( refreshWorkspacePermissionKeysAfterMutationDenialAtom, ) const canEdit = hasPermission(datasetDefaultPermissionKeys, DatasetACLPermission.Edit) const permissionPending = workspacePermissionKeysLoading const permissionQueryError = Boolean(workspacePermissionKeysError) const hasWorkspaceWritePermission = canEdit && !permissionPending && !permissionQueryError const uploadInputRef = useRef(null) const documentPermissionAlertRef = useRef(null) const writePermissionFocusRecoveryRequestedRef = useRef(false) const writePermissionFocusOriginRef = useRef(null) const uploadPendingRef = useRef(false) const reindexPendingRef = useRef(false) const [filter, setFilter] = useQueryState('status', documentFilterParser) const [search, setSearch] = useQueryState('query', documentSearchParser) const [selectedDocumentIds, setSelectedDocumentIds] = useState>(() => new Set()) const [tasksOpen, setTasksOpen] = useState(false) const [writePermissionRevoked, setWritePermissionRevoked] = useState(false) const [writePermissionRecoveryGeneration, setWritePermissionRecoveryGeneration] = useState< number | undefined >() const writePermissionDenialGenerationRef = useRef(0) const writePermissionRecoveryFetchSeenRef = useRef(false) const [blockingDependencyRetries, setBlockingDependencyRetries] = useState({ sources: false, tasks: false, }) const [taskStreamOffset, setTaskStreamOffset] = useState(0) const failedTaskPollOffsetRef = useRef(0) const [taskOverrides, setTaskOverrides] = useState< Record> >({}) const [terminalTaskPins, setTerminalTaskPins] = useState>({}) const [taskObserverGenerations, setTaskObserverGenerations] = useState>({}) const terminalReconciliationGenerationsRef = useRef(new Map()) const failedTaskPollGenerationsRef = useRef(new Map()) const blockedFailedTaskPollVersionsRef = useRef(new Map()) const failedPollAuxiliaryDenialsRef = useRef(new Map()) const terminalConfirmableAuxiliaryDenialsRef = useRef(new Map()) const equalRetryListGenerationsRef = useRef(new Map()) const terminalReconciliationTimeoutsRef = useRef(new Map()) const terminalReconciliationControllersRef = useRef(new Map()) const pendingTerminalProgressRef = useRef(new Map()) const taskEventCursorsRef = useRef(new Map()) const streamActiveOverrideVersionsRef = useRef(new Map()) const trustedActiveOverrideVersionsRef = useRef(new Map()) const trustedOverrideListGenerationsRef = useRef(new Map()) const taskProgressStoreRef = useRef | null>(null) if (!taskProgressStoreRef.current) taskProgressStoreRef.current = createTaskProgressStore() const taskProgressStore = taskProgressStoreRef.current const [uploading, setUploading] = useState(false) const [reindexing, setReindexing] = useState(false) const { mutateAsync: uploadDocument } = useMutation( consoleQuery.knowledgeFs.postKnowledgeSpacesByIdDocuments.mutationOptions(), ) const { mutateAsync: bulkUploadDocuments } = useMutation( consoleQuery.knowledgeFs.postKnowledgeSpacesByIdDocumentsBulk.mutationOptions(), ) const { mutateAsync: reindexDocuments } = useMutation( consoleQuery.knowledgeFs.postKnowledgeSpacesByIdDocumentsBulkReindex.mutationOptions(), ) const documentsQuery = useInfiniteQuery( consoleQuery.knowledgeFs.getKnowledgeSpacesByIdLogicalDocuments.infiniteOptions({ input: (pageParam) => ({ params: { id: knowledgeSpaceId }, query: { limit: DOCUMENT_PAGE_SIZE, ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), }, }), getNextPageParam: (lastPage) => lastPage.nextCursor, initialPageParam: null as string | null, }), ) const documentPermissionDenied = responseStatus(documentsQuery.error) === 403 const refetchDocumentsQuery = documentsQuery.refetch const { deny: denyAuxiliaryTaskRead, guard: auxiliaryTaskReadGuard, permissionDenied: auxiliaryReadPermissionDenied, retry: retryAuxiliaryTaskRead, } = useAuxiliaryTaskReadGuard({ documentPermissionDenied, refetchDocuments: refetchDocumentsQuery, }) useEffect(() => { if (!documentPermissionDenied) return failedPollAuxiliaryDenialsRef.current.clear() terminalConfirmableAuxiliaryDenialsRef.current.clear() }, [documentPermissionDenied]) const tasksQueryOptions = useMemo( () => consoleQuery.knowledgeFs.getKnowledgeSpacesByIdProcessingTasks.infiniteOptions({ enabled: !documentPermissionDenied, input: (pageParam) => ({ params: { id: knowledgeSpaceId }, query: { limit: TASK_PAGE_SIZE, ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), }, }), getNextPageParam: (lastPage) => lastPage.nextCursor, initialPageParam: null as string | null, }), [documentPermissionDenied, knowledgeSpaceId], ) const tasksQuery = useInfiniteQuery(tasksQueryOptions) const sourcesQuery = useInfiniteQuery( consoleQuery.knowledgeFs.getKnowledgeSpacesByIdSources.infiniteOptions({ enabled: !documentPermissionDenied, input: (pageParam) => ({ params: { id: knowledgeSpaceId }, query: { limit: TASK_PAGE_SIZE, ...(typeof pageParam === 'string' ? { cursor: pageParam } : {}), }, }), getNextPageParam: (lastPage) => lastPage.nextCursor, initialPageParam: null as string | null, }), ) const permissionDenialMask = (documentPermissionDenied || auxiliaryReadPermissionDenied ? DOCUMENT_PERMISSION_DENIED : 0) | (responseStatus(tasksQuery.error) === 403 ? TASK_PERMISSION_DENIED : 0) | (responseStatus(sourcesQuery.error) === 403 ? SOURCE_PERMISSION_DENIED : 0) const permissionDenied = permissionDenialMask !== 0 const previousPermissionDenialMaskRef = useRef(permissionDenialMask) const permissionRecoveryQueryMaskRef = useRef( recoveryQueryMaskForPermissionDenials(permissionDenialMask), ) const canWrite = hasWorkspaceWritePermission && !permissionDenied && !writePermissionRevoked const documentWriteRestrictionReasonId = permissionPending ? 'documents-permission-pending' : permissionQueryError ? 'documents-permission-error' : !canEdit || writePermissionRevoked ? 'documents-readonly-reason' : undefined const documentsRecoveryDescription = auxiliaryReadPermissionDenied ? t(($) => $['newKnowledge.documentsPermissionDescription']) : t(($) => $['newKnowledge.documentsErrorDescription']) const documentsSectionRef = useRef(null) const documentsTitleRef = useRef(null) const documentSurfaceHadFocusRef = useRef(false) const bulkActionsHadFocusRef = useRef(false) const mainRetryFocusRequestedRef = useRef(false) const permissionRetryButtonRef = useRef(null) const documentsRetryButtonRef = useRef(null) const dependencyRetryButtonRef = useRef(null) const { fetchNextPage: fetchNextDocumentPage, hasNextPage: hasNextDocumentPage, isFetchNextPageError: isFetchNextDocumentPageError, isFetchingNextPage: isFetchingNextDocumentPage, } = documentsQuery const { fetchNextPage: fetchNextTaskPage, hasNextPage: hasNextTaskPage, isFetchNextPageError: isFetchNextTaskPageError, isFetchingNextPage: isFetchingNextTaskPage, refetch: refetchTasksQuery, } = tasksQuery const { fetchNextPage: fetchNextSourcePage, hasNextPage: hasNextSourcePage, isFetchNextPageError: isFetchNextSourcePageError, isFetchingNextPage: isFetchingNextSourcePage, refetch: refetchSourcesQuery, } = sourcesQuery const canAutoFetchDocumentPage = Boolean( hasNextDocumentPage && (documentsQuery.data?.pages.length ?? 0) < MAX_AUTO_CURSOR_PAGES, ) const canAutoFetchTaskPage = Boolean( hasNextTaskPage && (tasksQuery.data?.pages.length ?? 0) < MAX_AUTO_CURSOR_PAGES, ) const documents = useMemo( () => documentsQuery.data?.pages.flatMap((page) => page.items) ?? [], [documentsQuery.data], ) const baseTasks = useMemo( () => tasksQuery.data?.pages.flatMap((page) => page.items) ?? [], [tasksQuery.data], ) const documentIds = useMemo(() => new Set(documents.map((document) => document.id)), [documents]) const unresolvedTaskDocumentIds = useMemo( () => new Set( baseTasks.flatMap((task) => (!documentIds.has(task.documentId) ? [task.documentId] : [])), ), [baseTasks, documentIds], ) const taskDataUpdateCount = useQueryDataUpdateCount(queryClient, tasksQueryOptions.queryKey) const taskListSnapshotRef = useRef({ data: tasksQuery.data, dataUpdateCount: taskDataUpdateCount, dataUpdatedAt: tasksQuery.dataUpdatedAt, }) const taskListGenerationRef = useRef(0) const [taskListGeneration, setTaskListGeneration] = useState(0) useLayoutEffect(() => { if ( taskListSnapshotRef.current.dataUpdateCount === taskDataUpdateCount && taskListSnapshotRef.current.dataUpdatedAt === tasksQuery.dataUpdatedAt && taskListSnapshotRef.current.data === tasksQuery.data ) return taskListSnapshotRef.current = { data: tasksQuery.data, dataUpdateCount: taskDataUpdateCount, dataUpdatedAt: tasksQuery.dataUpdatedAt, } taskListGenerationRef.current += 1 setTaskListGeneration(taskListGenerationRef.current) }, [taskDataUpdateCount, tasksQuery.data, tasksQuery.dataUpdatedAt]) const baseTaskById = useMemo(() => new Map(baseTasks.map((task) => [task.id, task])), [baseTasks]) const sourceNames = useMemo( () => new Map( (sourcesQuery.data?.pages.flatMap((page) => page.items) ?? []).map((source) => [ source.id, source.name, ]), ), [sourcesQuery.data], ) const unresolvedDocumentSourceIds = useMemo( () => new Set( documents.flatMap((document) => document.sourceId && !sourceNames.has(document.sourceId) ? [document.sourceId] : [], ), ), [documents, sourceNames], ) const hasRelevantNextSourcePage = Boolean(hasNextSourcePage && unresolvedDocumentSourceIds.size) const isFetchingNextResultsPage = Boolean( isFetchingNextDocumentPage || (hasNextTaskPage && isFetchingNextTaskPage) || (hasRelevantNextSourcePage && isFetchingNextSourcePage), ) const canAutoFetchSourcePage = Boolean( hasRelevantNextSourcePage && (sourcesQuery.data?.pages.length ?? 0) < MAX_AUTO_CURSOR_PAGES, ) const disabledSourceIds = useMemo( () => new Set( (sourcesQuery.data?.pages.flatMap((page) => page.items) ?? []) .filter((source) => source.status === 'disabled') .map((source) => source.id), ), [sourcesQuery.data], ) const baseTaskByIdRef = useRef(baseTaskById) useLayoutEffect(() => { baseTaskByIdRef.current = baseTaskById }, [baseTaskById]) const tasks = useMemo( () => baseTasks.map((task) => { const override = taskOverrides[task.id] const terminalTaskPin = terminalTaskPins[task.id] if ( terminalTaskPin && override && taskIsActive(task) && !taskVersionIsAfter(task.updatedAt, terminalTaskPin.observedAt) ) return { ...task, ...override } if (!override?.updatedAt) return override ? { ...task, ...override } : task if (taskVersionIsAfter(task.updatedAt, override.updatedAt)) return task const mergedTask = { ...task, ...override } if ( !taskIsActive(task) && taskIsActive(mergedTask) && !taskVersionIsAfter(override.updatedAt, task.updatedAt) && streamActiveOverrideVersionsRef.current.get(task.id) === override.updatedAt ) return task return mergedTask }), [baseTasks, taskOverrides, terminalTaskPins], ) const effectiveTaskById = useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]) useEffect(() => { for (const task of tasks) { if (!taskIsActive(task)) { taskEventCursorsRef.current.delete(task.id) streamActiveOverrideVersionsRef.current.delete(task.id) trustedActiveOverrideVersionsRef.current.delete(task.id) trustedOverrideListGenerationsRef.current.delete(task.id) } } }, [tasks]) useEffect(() => { const clearedActiveTaskIds: string[] = [] for (const task of baseTasks) { const denial = terminalConfirmableAuxiliaryDenialsRef.current.get(task.id) if (!denial || taskListGeneration <= denial.taskListGeneration) continue if (taskIsActive(task)) { if (!taskVersionIsAfter(task.updatedAt, denial.taskVersion)) continue auxiliaryTaskReadGuard.clearTask(task.id) terminalConfirmableAuxiliaryDenialsRef.current.delete(task.id) continue } if (!auxiliaryTaskReadGuard.clearThrough(task.id, task.updatedAt)) continue terminalConfirmableAuxiliaryDenialsRef.current.delete(task.id) const effectiveTask = effectiveTaskById.get(task.id) if (effectiveTask && taskIsActive(effectiveTask)) clearedActiveTaskIds.push(task.id) } if (!clearedActiveTaskIds.length) return // oxlint-disable-next-line eslint-react/set-state-in-effect -- Clearing a committed terminal guard must remount effective active observers. setTaskObserverGenerations((current) => { const next = { ...current } for (const taskId of clearedActiveTaskIds) next[taskId] = (next[taskId] ?? 0) + 1 return next }) }, [auxiliaryTaskReadGuard, baseTasks, effectiveTaskById, taskListGeneration]) const currentTaskStateRef = useRef(new Map(tasks.map((task) => [task.id, task.state]))) const currentTaskVersionRef = useRef(new Map(tasks.map((task) => [task.id, task.updatedAt]))) useLayoutEffect(() => { const currentTaskIds = new Set(tasks.map((task) => task.id)) for (const task of tasks) { const currentVersion = currentTaskVersionRef.current.get(task.id) if (!currentVersion || !taskVersionIsAfter(currentVersion, task.updatedAt)) { currentTaskStateRef.current.set(task.id, task.state) currentTaskVersionRef.current.set(task.id, task.updatedAt) } } for (const taskId of currentTaskVersionRef.current.keys()) { if (currentTaskIds.has(taskId)) continue currentTaskStateRef.current.delete(taskId) currentTaskVersionRef.current.delete(taskId) } }, [tasks]) const taskByDocument = useMemo(() => newestTaskByDocument(tasks), [tasks]) const documentStatuses = useMemo( () => new Map( documents.map((document) => [ document.id, documentDisplayStatus( document, taskByDocument.get(document.id), Boolean( document.sourceId && (disabledSourceIds.has(document.sourceId) || (!hasNextSourcePage && !sourcesQuery.error && !sourcesQuery.isFetchNextPageError && !sourceNames.has(document.sourceId))), ), ), ]), ), [ disabledSourceIds, documents, hasNextSourcePage, sourceNames, sourcesQuery.error, sourcesQuery.isFetchNextPageError, taskByDocument, ], ) const filterActive = filter !== 'all' || Boolean(search.trim()) const statusFilterActive = filter !== 'all' const availableDocumentIds = useMemo( () => new Set( documents .filter((document) => documentStatuses.get(document.id) !== 'disabled') .map((document) => document.id), ), [documents, documentStatuses], ) const validSelectedDocumentIds = useMemo( () => new Set( [...selectedDocumentIds].filter((documentId) => availableDocumentIds.has(documentId)), ), [availableDocumentIds, selectedDocumentIds], ) const filteredDocuments = useMemo(() => { const normalizedSearch = search.trim().toLocaleLowerCase() return documents.filter((document) => { const status = documentStatuses.get(document.id) if (filter !== 'all' && status !== filter) return false if (!normalizedSearch) return true const resolvedSourceName = (document.sourceId && sourceNames.get(document.sourceId)) ?? sourceName(document) return `${document.title} ${resolvedSourceName ?? ''}` .toLocaleLowerCase() .includes(normalizedSearch) }) }, [documentStatuses, documents, filter, search, sourceNames]) const completingFilteredResults = filterActive && !documentsQuery.isFetchNextPageError && (canAutoFetchDocumentPage || documentsQuery.isFetchingNextPage) const filteredResultsIncomplete = Boolean( (filterActive && (hasNextDocumentPage || documentsQuery.isFetchingNextPage || documentsQuery.isFetchNextPageError)) || (statusFilterActive && (hasNextTaskPage || tasksQuery.isFetchingNextPage || tasksQuery.isFetchNextPageError)) || (filterActive && unresolvedDocumentSourceIds.size > 0 && (hasNextSourcePage || sourcesQuery.isFetchingNextPage || sourcesQuery.isFetchNextPageError)), ) const taskQueryBlockingError = Boolean( !tasksQuery.data && (tasksQuery.error || blockingDependencyRetries.tasks), ) const sourceQueryBlockingError = Boolean( !sourcesQuery.data && (sourcesQuery.error || blockingDependencyRetries.sources), ) const dependencyQueryBlockingError = taskQueryBlockingError || sourceQueryBlockingError const dependencyQueryWarning = Boolean( (tasksQuery.error && tasksQuery.data) || tasksQuery.isFetchNextPageError || (sourcesQuery.error && sourcesQuery.data) || sourcesQuery.isFetchNextPageError, ) const taskResultsIncomplete = Boolean( !tasksQuery.data || tasksQuery.isPending || tasksQuery.isFetchingNextPage, ) const sourceResultsIncomplete = Boolean( !sourcesQuery.data || sourcesQuery.isPending || (unresolvedDocumentSourceIds.size && (hasNextSourcePage || sourcesQuery.error || sourcesQuery.isFetchingNextPage || sourcesQuery.isFetchNextPageError)), ) const sourceQueryWarning = Boolean( (sourcesQuery.error && sourcesQuery.data) || sourcesQuery.isFetchNextPageError, ) const taskQueryWarning = Boolean( (tasksQuery.error && tasksQuery.data) || tasksQuery.isFetchNextPageError, ) const documentQueryWarning = Boolean(documentsQuery.error && documentsQuery.data) const dependencyRetryFetching = Boolean( (taskQueryWarning && tasksQuery.isFetching) || (sourceQueryWarning && sourcesQuery.isFetching), ) const blockingDependencyRetryFetching = Boolean( (taskQueryBlockingError && tasksQuery.isFetching) || (sourceQueryBlockingError && sourcesQuery.isFetching), ) const mainRecoveryVisible = Boolean( permissionQueryError || documentsQuery.error || dependencyQueryBlockingError || dependencyQueryWarning, ) const mainRecoveryIdentity = [ permissionQueryError ? 'permission' : '', documentsQuery.error ? 'documents' : '', taskQueryBlockingError ? 'tasks-blocking' : '', sourceQueryBlockingError ? 'sources-blocking' : '', taskQueryWarning ? 'tasks-warning' : '', sourceQueryWarning ? 'sources-warning' : '', ].join(':') const dependencyResultsIncomplete = taskResultsIncomplete || sourceResultsIncomplete const selectionDisabled = !canWrite || dependencyResultsIncomplete || documentQueryWarning || taskQueryWarning || (sourceQueryWarning && unresolvedDocumentSourceIds.size > 0) || filteredResultsIncomplete const reindexUnavailableReason = tasksQuery.error || tasksQuery.isFetchNextPageError ? t(($) => $['newKnowledge.tasksErrorDescription']) : (sourcesQuery.error || sourcesQuery.isFetchNextPageError) && unresolvedDocumentSourceIds.size > 0 ? t(($) => $['newKnowledge.sourcesErrorDescription']) : documentsQuery.error || documentsQuery.isFetchNextPageError ? t(($) => $['newKnowledge.documentsErrorDescription']) : dependencyResultsIncomplete ? tCommon(($) => $.loading) : filteredResultsIncomplete ? t(($) => $['newKnowledge.partialDocumentResults']) : undefined const selectableFilteredDocuments = filteredDocuments.filter( (document) => documentStatuses.get(document.id) !== 'disabled', ) const attentionTasks = tasks.filter(taskNeedsAttention) const hasTaskError = attentionTasks.some( (task) => task.state === 'failed' || task.state === 'canceled', ) const activeTasks = useMemo(() => tasks.filter(taskIsActive), [tasks]) const orderedActiveTasks = useMemo( () => [...activeTasks].sort( (left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), ), [activeTasks], ) const taskObserverVersion = (task: DocumentProcessingTask) => { let latestVersion = task.updatedAt for (const candidate of [ currentTaskVersionRef.current.get(task.id), taskProgressStore.get(task.id)?.updatedAt, ]) { if (candidate && taskVersionIsAfter(candidate, latestVersion)) latestVersion = candidate } return latestVersion } const streamedActiveTasks = (() => { if (permissionDenied) return [] const streamableActiveTasks = orderedActiveTasks.filter( (task) => !auxiliaryTaskReadGuard.isBlocked(task.id, taskObserverVersion(task)), ) const streamCount = Math.min(MAX_TASK_EVENT_STREAMS, streamableActiveTasks.length) if (!streamCount) return [] const offset = taskStreamOffset % streamableActiveTasks.length return Array.from( { length: streamCount }, (_, index) => streamableActiveTasks[(offset + index) % streamableActiveTasks.length]!, ) })() const blockedActiveTaskSignature = orderedActiveTasks .map((task) => [task.id, taskObserverVersion(task)] as const) .filter(([taskId, taskVersion]) => auxiliaryTaskReadGuard.isBlocked(taskId, taskVersion)) .map(([taskId, taskVersion]) => `${taskId}:${taskVersion}`) .join('|') const orderedFailedTasks = useMemo( () => tasks .filter((task) => task.state === 'failed') .sort((left, right) => { if (taskVersionIsAfter(left.updatedAt, right.updatedAt)) return -1 if (taskVersionIsAfter(right.updatedAt, left.updatedAt)) return 1 return right.id.localeCompare(left.id) }), [tasks], ) const orderedFailedTasksRef = useRef(orderedFailedTasks) useLayoutEffect(() => { orderedFailedTasksRef.current = orderedFailedTasks }, [orderedFailedTasks]) const failedTaskPollSignature = orderedFailedTasks .map((task) => `${task.id}:${task.updatedAt}`) .join('|') const incompleteTaskHistoryHint = hasNextTaskPage ? ` · ${t(($) => $['newKnowledge.taskHistoryIncomplete'])}` : '' const attentionTaskBadge = attentionTasks.length || hasNextTaskPage ? `${attentionTasks.length}${hasNextTaskPage ? '+' : ''}` : undefined const tasksButtonLabel = `${ attentionTasks.length || hasNextTaskPage ? t(($) => $['newKnowledge.tasksWithAttention'], { count: attentionTasks.length }) : t(($) => $['newKnowledge.tasks']) }${incompleteTaskHistoryHint}` const tasksLiveStatus = `${ hasTaskError ? t(($) => $['newKnowledge.taskAttentionErrorCount'], { count: attentionTasks.length }) : attentionTasks.length || hasNextTaskPage ? t(($) => $['newKnowledge.taskAttentionCount'], { count: attentionTasks.length }) : t(($) => $['newKnowledge.taskAttentionClear']) }${incompleteTaskHistoryHint}` const allFilteredSelected = selectableFilteredDocuments.length > 0 && selectableFilteredDocuments.every((document) => validSelectedDocumentIds.has(document.id)) const someFilteredSelected = selectableFilteredDocuments.some((document) => validSelectedDocumentIds.has(document.id), ) const bulkActionsVisible = canWrite && validSelectedDocumentIds.size > 0 const previousBulkActionsVisibleRef = useRef(bulkActionsVisible) useLayoutEffect(() => { const wasVisible = previousBulkActionsVisibleRef.current previousBulkActionsVisibleRef.current = bulkActionsVisible if (!wasVisible || bulkActionsVisible || !bulkActionsHadFocusRef.current) return if (permissionDenied) return bulkActionsHadFocusRef.current = false documentsTitleRef.current?.focus() }, [bulkActionsVisible, permissionDenied]) useEffect(() => { if (permissionDenied || orderedActiveTasks.length <= MAX_TASK_EVENT_STREAMS) return const interval = window.setInterval( () => setTaskStreamOffset((current) => current + MAX_TASK_EVENT_STREAMS), 5000, ) return () => window.clearInterval(interval) }, [orderedActiveTasks.length, permissionDenied]) useEffect(() => { if (permissionDenied || !blockedActiveTaskSignature) return let canceled = false let refreshInterval = BLOCKED_ACTIVE_TASK_REFRESH_INTERVAL let timeout: number | undefined const refreshBlockedTasks = async () => { if (canceled) return await refetchTasksQuery({ cancelRefetch: false }).catch(() => undefined) if (canceled) return refreshInterval = Math.min(refreshInterval * 2, MAX_BLOCKED_ACTIVE_TASK_REFRESH_INTERVAL) timeout = window.setTimeout(refreshBlockedTasks, refreshInterval) } timeout = window.setTimeout(refreshBlockedTasks, BLOCKED_ACTIVE_TASK_REFRESH_INTERVAL) return () => { canceled = true if (timeout !== undefined) window.clearTimeout(timeout) } }, [blockedActiveTaskSignature, permissionDenied, refetchTasksQuery]) useLayoutEffect(() => { if (!writePermissionRevoked || !writePermissionFocusRecoveryRequestedRef.current) return writePermissionFocusRecoveryRequestedRef.current = false writePermissionFocusOriginRef.current = null documentsTitleRef.current?.focus() }, [writePermissionRevoked]) useEffect(() => { const previousPermissionDenialMask = previousPermissionDenialMaskRef.current previousPermissionDenialMaskRef.current = permissionDenialMask const addedPermissionDenials = permissionDenialMask & ~previousPermissionDenialMask let recoveryQueryMask = permissionRecoveryQueryMaskRef.current | recoveryQueryMaskForPermissionDenials(addedPermissionDenials) if ( previousPermissionDenialMask & TASK_PERMISSION_DENIED && !(permissionDenialMask & TASK_PERMISSION_DENIED) ) recoveryQueryMask &= ~TASK_PERMISSION_DENIED if ( previousPermissionDenialMask & SOURCE_PERMISSION_DENIED && !(permissionDenialMask & SOURCE_PERMISSION_DENIED) ) recoveryQueryMask &= ~SOURCE_PERMISSION_DENIED recoveryQueryMask &= ~permissionDenialMask permissionRecoveryQueryMaskRef.current = recoveryQueryMask if (previousPermissionDenialMask && !permissionDenied) { blockedFailedTaskPollVersionsRef.current.clear() if (recoveryQueryMask & TASK_PERMISSION_DENIED) void refetchTasksQuery({ cancelRefetch: false }) if (recoveryQueryMask & SOURCE_PERMISSION_DENIED) void refetchSourcesQuery({ cancelRefetch: false }) permissionRecoveryQueryMaskRef.current = 0 if (documentSurfaceHadFocusRef.current || bulkActionsHadFocusRef.current) documentsTitleRef.current?.focus() bulkActionsHadFocusRef.current = false return } if (previousPermissionDenialMask || !permissionDenied) return const shouldRestoreFocus = tasksOpen || documentSurfaceHadFocusRef.current || bulkActionsHadFocusRef.current if (tasksOpen) { // oxlint-disable-next-line eslint-react/set-state-in-effect -- Permission revocation permanently closes the controlled task drawer. setTasksOpen(false) } if (shouldRestoreFocus) documentPermissionAlertRef.current?.focus() bulkActionsHadFocusRef.current = false }, [permissionDenialMask, permissionDenied, refetchSourcesQuery, refetchTasksQuery, tasksOpen]) useEffect(() => { if ( !writePermissionRevoked || writePermissionRecoveryGeneration !== writePermissionDenialGenerationRef.current ) { writePermissionRecoveryFetchSeenRef.current = false return } if (workspacePermissionKeysFetching) { writePermissionRecoveryFetchSeenRef.current = true return } if ( !writePermissionRecoveryFetchSeenRef.current || permissionPending || permissionQueryError || !canEdit ) return writePermissionRecoveryFetchSeenRef.current = false // oxlint-disable-next-line eslint-react/set-state-in-effect -- A post-denial permission request is the authoritative event that retires the local mutation lock. setWritePermissionRevoked(false) // oxlint-disable-next-line eslint-react/set-state-in-effect -- The completed recovery generation is retired with its write lock. setWritePermissionRecoveryGeneration(undefined) }, [ canEdit, permissionPending, permissionQueryError, workspacePermissionKeysFetching, writePermissionRecoveryGeneration, writePermissionRevoked, ]) useEffect(() => { if (!mainRetryFocusRequestedRef.current) return if (mainRecoveryVisible) { if (documentPermissionDenied) { mainRetryFocusRequestedRef.current = false documentPermissionAlertRef.current?.focus() } else if (permissionQueryError) permissionRetryButtonRef.current?.focus() else if (documentsQuery.error && !permissionDenied) documentsRetryButtonRef.current?.focus() else dependencyRetryButtonRef.current?.focus() return } mainRetryFocusRequestedRef.current = false documentsTitleRef.current?.focus() }, [ documentsQuery.error, documentPermissionDenied, mainRecoveryIdentity, mainRecoveryVisible, permissionDenied, permissionQueryError, ]) useEffect(() => { if (!blockingDependencyRetries.tasks && !blockingDependencyRetries.sources) return if ( (!blockingDependencyRetries.tasks || !tasksQuery.data) && (!blockingDependencyRetries.sources || !sourcesQuery.data) ) return // oxlint-disable-next-line eslint-react/set-state-in-effect -- Successful first-page retries retire the locally stabilized blocking state. setBlockingDependencyRetries((current) => ({ sources: current.sources && !sourcesQuery.data, tasks: current.tasks && !tasksQuery.data, })) }, [blockingDependencyRetries, sourcesQuery.data, tasksQuery.data]) useEffect(() => { if ( !permissionDenied && (filterActive || (tasksOpen && unresolvedTaskDocumentIds.size > 0)) && canAutoFetchDocumentPage && !isFetchingNextDocumentPage && !isFetchNextDocumentPageError ) void fetchNextDocumentPage() }, [ fetchNextDocumentPage, filterActive, tasksOpen, unresolvedTaskDocumentIds, canAutoFetchDocumentPage, isFetchNextDocumentPageError, isFetchingNextDocumentPage, permissionDenied, ]) useEffect(() => { if ( !permissionDenied && canAutoFetchTaskPage && !isFetchingNextTaskPage && !isFetchNextTaskPageError ) void fetchNextTaskPage() }, [ canAutoFetchTaskPage, fetchNextTaskPage, isFetchNextTaskPageError, isFetchingNextTaskPage, permissionDenied, ]) useEffect(() => { if ( !permissionDenied && canAutoFetchSourcePage && !isFetchingNextSourcePage && !isFetchNextSourcePageError ) void fetchNextSourcePage() }, [ canAutoFetchSourcePage, fetchNextSourcePage, isFetchNextSourcePageError, isFetchingNextSourcePage, permissionDenied, ]) const refreshDocuments = useCallback(() => { void queryClient.invalidateQueries({ predicate: (query) => queryKeyMatchesKnowledgeSpace(query.queryKey, knowledgeSpaceId), queryKey: consoleQuery.knowledgeFs.getKnowledgeSpacesByIdLogicalDocuments.key(), }) }, [knowledgeSpaceId, queryClient]) const refreshDocumentsAndTasks = useCallback(() => { void Promise.allSettled([ queryClient.invalidateQueries({ predicate: (query) => queryKeyMatchesKnowledgeSpace(query.queryKey, knowledgeSpaceId), queryKey: consoleQuery.knowledgeFs.getKnowledgeSpacesByIdLogicalDocuments.key(), }), queryClient.invalidateQueries({ predicate: (query) => queryKeyMatchesKnowledgeSpace(query.queryKey, knowledgeSpaceId), queryKey: consoleQuery.knowledgeFs.getKnowledgeSpacesByIdProcessingTasks.key(), }), ]) }, [knowledgeSpaceId, queryClient]) const refreshWorkspacePermissions = useCallback( async (releaseWriteLockOnSuccess: boolean) => { const denialGeneration = writePermissionDenialGenerationRef.current const result = await (releaseWriteLockOnSuccess && writePermissionRevoked ? refreshWorkspacePermissionKeysAfterMutationDenial() : retryWorkspacePermissionKeys()) const refreshedPermissionKeys = result.data?.dataset?.default_permission_keys if ( releaseWriteLockOnSuccess && writePermissionDenialGenerationRef.current === denialGeneration && !result.error && refreshedPermissionKeys && hasPermission(refreshedPermissionKeys, DatasetACLPermission.Edit) ) { writePermissionRecoveryFetchSeenRef.current = false setWritePermissionRevoked(false) setWritePermissionRecoveryGeneration(undefined) } }, [ refreshWorkspacePermissionKeysAfterMutationDenial, retryWorkspacePermissionKeys, writePermissionRevoked, ], ) const handleWritePermissionDenied = useCallback(() => { const denialGeneration = writePermissionDenialGenerationRef.current + 1 writePermissionDenialGenerationRef.current = denialGeneration writePermissionRecoveryFetchSeenRef.current = false setWritePermissionRecoveryGeneration(undefined) setWritePermissionRevoked(true) void refreshWorkspacePermissionKeysAfterMutationDenial().then((result) => { const refreshedPermissionKeys = result.data?.dataset?.default_permission_keys if (writePermissionDenialGenerationRef.current !== denialGeneration) return if ( !result.error && refreshedPermissionKeys && hasPermission(refreshedPermissionKeys, DatasetACLPermission.Edit) ) { writePermissionRecoveryFetchSeenRef.current = false setWritePermissionRevoked(false) setWritePermissionRecoveryGeneration(undefined) return } setWritePermissionRecoveryGeneration(denialGeneration) }) }, [refreshWorkspacePermissionKeysAfterMutationDenial]) const reconcileTerminalTask = useCallback( async function reconcileTerminalTaskRequest( taskId: string, terminalVersion: string, reconciliationGeneration: number, retryAttempt = 0, ) { const currentTask = baseTaskByIdRef.current.get(taskId) if (!currentTask || auxiliaryTaskReadGuard.isBlocked(taskId, terminalVersion)) return const pendingRetryTimeout = terminalReconciliationTimeoutsRef.current.get(taskId) if (pendingRetryTimeout !== undefined) window.clearTimeout(pendingRetryTimeout) terminalReconciliationTimeoutsRef.current.delete(taskId) terminalReconciliationControllersRef.current.get(taskId)?.abort() const controller = new AbortController() terminalReconciliationControllersRef.current.set(taskId, controller) const requestTimeout = window.setTimeout( () => controller.abort(), TERMINAL_RECONCILIATION_REQUEST_TIMEOUT, ) try { const snapshot = await consoleClient.knowledgeFs.getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId( { params: { documentId: currentTask.documentId, id: knowledgeSpaceId, taskId, }, }, { signal: controller.signal }, ) if ( terminalReconciliationControllersRef.current.get(taskId) !== controller || terminalReconciliationGenerationsRef.current.get(taskId) !== reconciliationGeneration ) return terminalReconciliationControllersRef.current.delete(taskId) const currentTaskVersion = currentTaskVersionRef.current.get(taskId) if (currentTaskVersion && taskVersionIsAfter(currentTaskVersion, snapshot.updatedAt)) return if (taskVersionIsAfter(terminalVersion, snapshot.updatedAt)) return auxiliaryTaskReadGuard.clearTask(taskId) failedPollAuxiliaryDenialsRef.current.delete(taskId) terminalConfirmableAuxiliaryDenialsRef.current.delete(taskId) const normalizedSnapshot = normalizedTaskSnapshot(snapshot) if (taskIsActive(snapshot)) trustedActiveOverrideVersionsRef.current.set(taskId, { taskListGeneration: taskListGenerationRef.current, updatedAt: snapshot.updatedAt, }) else trustedActiveOverrideVersionsRef.current.delete(taskId) taskProgressStore.delete(taskId) currentTaskStateRef.current.set(taskId, snapshot.state) currentTaskVersionRef.current.set(taskId, snapshot.updatedAt) setTaskOverrides((current) => { const currentVersion = current[taskId]?.updatedAt if (currentVersion && taskVersionIsAfter(currentVersion, snapshot.updatedAt)) return current return { ...current, [taskId]: normalizedSnapshot } }) if (taskIsActive(snapshot)) { blockedFailedTaskPollVersionsRef.current.delete(taskId) taskEventCursorsRef.current.delete(taskId) const pollGeneration = failedTaskPollGenerationsRef.current.get(taskId) ?? 0 failedTaskPollGenerationsRef.current.set(taskId, pollGeneration + 1) setTerminalTaskPins((current) => { const pin = current[taskId] if (!pin || taskVersionIsAfter(pin.observedAt, snapshot.updatedAt)) return current const next = { ...current } delete next[taskId] return next }) setTaskObserverGenerations((current) => ({ ...current, [taskId]: (current[taskId] ?? 0) + 1, })) } } catch (error) { if (terminalReconciliationControllersRef.current.get(taskId) !== controller) return terminalReconciliationControllersRef.current.delete(taskId) if (responseStatus(error) === 403) { const currentTaskVersion = currentTaskVersionRef.current.get(taskId) const deniedVersion = currentTaskVersion && taskVersionIsAfter(currentTaskVersion, terminalVersion) ? currentTaskVersion : terminalVersion terminalConfirmableAuxiliaryDenialsRef.current.set(taskId, { taskListGeneration: taskListGenerationRef.current, taskVersion: deniedVersion, }) denyAuxiliaryTaskRead(taskId, deniedVersion) return } if ( retryAttempt >= 4 || !taskSnapshotErrorIsTransient(error) || terminalReconciliationGenerationsRef.current.get(taskId) !== reconciliationGeneration ) return const timeout = window.setTimeout( () => { terminalReconciliationTimeoutsRef.current.delete(taskId) if ( terminalReconciliationGenerationsRef.current.get(taskId) === reconciliationGeneration ) void reconcileTerminalTaskRequest( taskId, terminalVersion, reconciliationGeneration, retryAttempt + 1, ) }, Math.min(1000 * 2 ** retryAttempt, 30000), ) terminalReconciliationTimeoutsRef.current.set(taskId, timeout) } finally { window.clearTimeout(requestTimeout) } }, [auxiliaryTaskReadGuard, denyAuxiliaryTaskRead, knowledgeSpaceId, taskProgressStore], ) useEffect(() => { if (permissionDenied) return for (const task of baseTasks) { if (taskIsActive(task)) continue const override = taskOverrides[task.id] const trustedOverride = trustedActiveOverrideVersionsRef.current.get(task.id) if ( !override?.updatedAt || !taskIsActive({ ...task, ...override }) || taskVersionIsAfter(override.updatedAt, task.updatedAt) || trustedOverride?.updatedAt !== override.updatedAt || taskListGeneration <= trustedOverride.taskListGeneration || trustedOverrideListGenerationsRef.current.get(task.id) === taskListGeneration ) continue trustedOverrideListGenerationsRef.current.set(task.id, taskListGeneration) const reconciliationGeneration = (terminalReconciliationGenerationsRef.current.get(task.id) ?? 0) + 1 terminalReconciliationGenerationsRef.current.set(task.id, reconciliationGeneration) void reconcileTerminalTask(task.id, task.updatedAt, reconciliationGeneration) } }, [baseTasks, permissionDenied, reconcileTerminalTask, taskListGeneration, taskOverrides]) useEffect( () => () => { for (const controller of terminalReconciliationControllersRef.current.values()) controller.abort() terminalReconciliationControllersRef.current.clear() for (const timeout of terminalReconciliationTimeoutsRef.current.values()) window.clearTimeout(timeout) terminalReconciliationTimeoutsRef.current.clear() }, [knowledgeSpaceId], ) useEffect(() => { if (!permissionDenied) return void queryClient.cancelQueries({ predicate: (query) => queryKeyMatchesKnowledgeSpace(query.queryKey, knowledgeSpaceId), queryKey: consoleQuery.knowledgeFs.getKnowledgeSpacesByIdProcessingTasks.key(), }) void queryClient.cancelQueries({ predicate: (query) => queryKeyMatchesKnowledgeSpace(query.queryKey, knowledgeSpaceId), queryKey: consoleQuery.knowledgeFs.getKnowledgeSpacesByIdSources.key(), }) for (const [taskId, controller] of terminalReconciliationControllersRef.current) { controller.abort() terminalReconciliationControllersRef.current.delete(taskId) const generation = terminalReconciliationGenerationsRef.current.get(taskId) ?? 0 terminalReconciliationGenerationsRef.current.set(taskId, generation + 1) } for (const timeout of terminalReconciliationTimeoutsRef.current.values()) window.clearTimeout(timeout) terminalReconciliationTimeoutsRef.current.clear() equalRetryListGenerationsRef.current.clear() }, [knowledgeSpaceId, permissionDenied, queryClient]) useEffect(() => { const taskIds = new Set(baseTasks.map((task) => task.id)) const pruneMap = (map: Map) => { for (const taskId of map.keys()) { if (!taskIds.has(taskId)) map.delete(taskId) } } for (const [taskId, controller] of terminalReconciliationControllersRef.current) { if (taskIds.has(taskId)) continue controller.abort() terminalReconciliationControllersRef.current.delete(taskId) } for (const [taskId, timeout] of terminalReconciliationTimeoutsRef.current) { if (taskIds.has(taskId)) continue window.clearTimeout(timeout) terminalReconciliationTimeoutsRef.current.delete(taskId) } pruneMap(terminalReconciliationGenerationsRef.current) pruneMap(failedTaskPollGenerationsRef.current) pruneMap(blockedFailedTaskPollVersionsRef.current) pruneMap(failedPollAuxiliaryDenialsRef.current) pruneMap(terminalConfirmableAuxiliaryDenialsRef.current) auxiliaryTaskReadGuard.retain(taskIds) pruneMap(equalRetryListGenerationsRef.current) pruneMap(pendingTerminalProgressRef.current) pruneMap(taskEventCursorsRef.current) pruneMap(trustedOverrideListGenerationsRef.current) for (const map of [ streamActiveOverrideVersionsRef.current, trustedActiveOverrideVersionsRef.current, ]) { for (const taskId of map.keys()) { if (!taskIds.has(taskId)) map.delete(taskId) } } taskProgressStore.retain(taskIds) const retainTaskState = (current: Record) => { const staleTaskIds = Object.keys(current).filter((taskId) => !taskIds.has(taskId)) if (!staleTaskIds.length) return current const next = { ...current } for (const taskId of staleTaskIds) delete next[taskId] return next } // oxlint-disable-next-line eslint-react/set-state-in-effect -- Prune state for tasks removed by a refreshed cursor result. setTaskOverrides(retainTaskState) // oxlint-disable-next-line eslint-react/set-state-in-effect -- Prune state for tasks removed by a refreshed cursor result. setTerminalTaskPins(retainTaskState) // oxlint-disable-next-line eslint-react/set-state-in-effect -- Prune state for tasks removed by a refreshed cursor result. setTaskObserverGenerations(retainTaskState) }, [auxiliaryTaskReadGuard, baseTasks, taskProgressStore]) useEffect(() => { if (permissionDenied) return const strictRetries = new Map() const equalTimestampRetries = new Map() const confirmedTerminals = new Map() for (const task of baseTasks) { const pin = terminalTaskPins[task.id] if (!pin || taskListGeneration <= pin.taskListGeneration) continue if (!taskIsActive(task)) { if (!taskVersionIsAfter(pin.observedAt, task.updatedAt)) confirmedTerminals.set(task.id, task) continue } if (taskVersionIsAfter(task.updatedAt, pin.observedAt)) strictRetries.set(task.id, task) else if (!taskVersionIsAfter(pin.observedAt, task.updatedAt)) equalTimestampRetries.set(task.id, pin) } for (const taskId of confirmedTerminals.keys()) { const generation = terminalReconciliationGenerationsRef.current.get(taskId) ?? 0 terminalReconciliationGenerationsRef.current.set(taskId, generation + 1) terminalReconciliationControllersRef.current.get(taskId)?.abort() terminalReconciliationControllersRef.current.delete(taskId) const timeout = terminalReconciliationTimeoutsRef.current.get(taskId) if (timeout !== undefined) window.clearTimeout(timeout) terminalReconciliationTimeoutsRef.current.delete(taskId) } if (confirmedTerminals.size) { // oxlint-disable-next-line eslint-react/set-state-in-effect -- A current terminal list snapshot supersedes the partial SSE terminal payload. setTerminalTaskPins((current) => { const next = { ...current } for (const taskId of confirmedTerminals.keys()) delete next[taskId] return next }) // oxlint-disable-next-line eslint-react/set-state-in-effect -- Restore complete server error details from the authoritative terminal list snapshot. setTaskOverrides((current) => { const next = { ...current } for (const [taskId, task] of confirmedTerminals) { const overrideVersion = current[taskId]?.updatedAt if (!overrideVersion || !taskVersionIsAfter(overrideVersion, task.updatedAt)) delete next[taskId] } return next }) } for (const taskId of strictRetries.keys()) { taskProgressStore.delete(taskId) blockedFailedTaskPollVersionsRef.current.delete(taskId) auxiliaryTaskReadGuard.clearTask(taskId) failedPollAuxiliaryDenialsRef.current.delete(taskId) terminalConfirmableAuxiliaryDenialsRef.current.delete(taskId) taskEventCursorsRef.current.delete(taskId) const pollGeneration = failedTaskPollGenerationsRef.current.get(taskId) ?? 0 failedTaskPollGenerationsRef.current.set(taskId, pollGeneration + 1) const generation = terminalReconciliationGenerationsRef.current.get(taskId) ?? 0 terminalReconciliationGenerationsRef.current.set(taskId, generation + 1) terminalReconciliationControllersRef.current.get(taskId)?.abort() terminalReconciliationControllersRef.current.delete(taskId) const timeout = terminalReconciliationTimeoutsRef.current.get(taskId) if (timeout !== undefined) window.clearTimeout(timeout) terminalReconciliationTimeoutsRef.current.delete(taskId) } if (strictRetries.size) { // oxlint-disable-next-line eslint-react/set-state-in-effect -- A strictly newer active list snapshot proves a retry without another request. setTerminalTaskPins((current) => { const next = { ...current } for (const taskId of strictRetries.keys()) delete next[taskId] return next }) // oxlint-disable-next-line eslint-react/set-state-in-effect -- Drop terminal overrides only after a strictly newer active list snapshot. setTaskOverrides((current) => { const next = { ...current } for (const [taskId, task] of strictRetries) { const overrideVersion = current[taskId]?.updatedAt if (!overrideVersion || !taskVersionIsAfter(overrideVersion, task.updatedAt)) delete next[taskId] } return next }) } for (const [taskId, pin] of equalTimestampRetries) { if (equalRetryListGenerationsRef.current.get(taskId) === taskListGeneration) continue equalRetryListGenerationsRef.current.set(taskId, taskListGeneration) auxiliaryTaskReadGuard.clearTask(taskId) failedPollAuxiliaryDenialsRef.current.delete(taskId) terminalConfirmableAuxiliaryDenialsRef.current.delete(taskId) const timeout = terminalReconciliationTimeoutsRef.current.get(taskId) if (timeout !== undefined) window.clearTimeout(timeout) terminalReconciliationTimeoutsRef.current.delete(taskId) const reconciliationGeneration = (terminalReconciliationGenerationsRef.current.get(taskId) ?? 0) + 1 terminalReconciliationGenerationsRef.current.set(taskId, reconciliationGeneration) void reconcileTerminalTask(taskId, pin.observedAt, reconciliationGeneration) } }, [ auxiliaryTaskReadGuard, baseTasks, permissionDenied, reconcileTerminalTask, taskListGeneration, taskProgressStore, terminalTaskPins, ]) const handleUploadFiles = useCallback( async (files: File[]) => { if (!canWrite || !files.length || uploadPendingRef.current) return const uploadableFiles: File[] = [] const localExclusions: Array<{ filename: string reasonKey: UploadExclusionReasonKey }> = [] for (const file of files) { const issue = documentUploadIssue(file) if (issue) localExclusions.push({ filename: file.name, reasonKey: issue }) else uploadableFiles.push(file) } const formatExclusionDetails = ( exclusions: Array<{ filename: string; reasonKey: UploadExclusionReasonKey }>, ) => { const detailItems = exclusions .slice(0, 3) .map( ({ filename, reasonKey }) => `${filename} (${t(($) => $[`newKnowledge.documentUploadExclusion.${reasonKey}`])})`, ) if (exclusions.length > detailItems.length) detailItems.push( t(($) => $['newKnowledge.documentUploadExclusion.more'], { count: exclusions.length - detailItems.length, }), ) return detailItems.join('; ') } if (!uploadableFiles.length) { toast.error( t(($) => $['newKnowledge.documentUploadRejected'], { details: formatExclusionDetails(localExclusions), }), ) return } let writePermissionDenied = false uploadPendingRef.current = true setUploading(true) try { let acceptedCount = 0 const exclusions = [...localExclusions] if (uploadableFiles.length === 1) { await uploadDocument({ body: { file: uploadableFiles[0]! }, params: { id: knowledgeSpaceId }, }) acceptedCount = 1 } else { const result = await bulkUploadDocuments({ body: { files: uploadableFiles }, params: { id: knowledgeSpaceId }, }) acceptedCount = result.accepted for (const item of result.items) { if (!('reason' in item)) continue const reasonKey = uploadExclusionReasonKey[item.reason] exclusions.push({ filename: item.filename, reasonKey }) } } const exclusionDetails = formatExclusionDetails(exclusions) if (!acceptedCount) { toast.error( t(($) => $['newKnowledge.documentUploadRejected'], { details: exclusionDetails, }), ) return } if (exclusions.length) toast.warning( t(($) => $['newKnowledge.documentUploadPartial'], { accepted: acceptedCount, details: exclusionDetails, excluded: exclusions.length, }), ) else toast.success(t(($) => $['newKnowledge.documentUploadStarted'])) refreshDocumentsAndTasks() } catch (error) { if (responseStatus(error) === 403) { writePermissionDenied = true handleWritePermissionDenied() } else toast.error(t(($) => $['newKnowledge.documentUploadFailed'])) } finally { if (!writePermissionDenied) { writePermissionFocusRecoveryRequestedRef.current = false writePermissionFocusOriginRef.current = null } uploadPendingRef.current = false setUploading(false) } }, [ bulkUploadDocuments, canWrite, handleWritePermissionDenied, knowledgeSpaceId, refreshDocumentsAndTasks, t, uploadDocument, ], ) const handleReindexDocuments = useCallback(async () => { if ( !canWrite || selectionDisabled || !validSelectedDocumentIds.size || reindexPendingRef.current ) return reindexPendingRef.current = true setReindexing(true) try { const selectedIds = [...validSelectedDocumentIds].sort() const result = await reindexDocuments({ body: { documentIds: selectedIds }, params: { id: knowledgeSpaceId }, }) const missingIds = result.items .filter((item) => item.status === 'not_found') .map((item) => item.documentId) const queuedCount = result.items.length - missingIds.length if (!queuedCount) { setSelectedDocumentIds(new Set()) toast.error( t(($) => $['newKnowledge.documentsReindexPartial'], { missing: missingIds.length, queued: 0, }), ) refreshDocumentsAndTasks() return } setSelectedDocumentIds(new Set(missingIds)) if (missingIds.length) toast.warning( t(($) => $['newKnowledge.documentsReindexPartial'], { missing: missingIds.length, queued: queuedCount, }), ) else toast.success(t(($) => $['newKnowledge.documentsReindexStarted'])) refreshDocumentsAndTasks() } catch (error) { if (responseStatus(error) === 403) handleWritePermissionDenied() else toast.error(t(($) => $['newKnowledge.documentsReindexFailed'])) } finally { reindexPendingRef.current = false setReindexing(false) } }, [ canWrite, handleWritePermissionDenied, knowledgeSpaceId, refreshDocumentsAndTasks, reindexDocuments, selectionDisabled, t, validSelectedDocumentIds, ]) const handleTaskEvent = useCallback( (taskId: string, taskVersion: string, event: ProcessingTaskEvent) => { const eventVersion = event.event === 'progress' ? event.data.updatedAt : taskVersion const terminalSnapshot = !ACTIVE_TASK_STATES.has(event.data.state) const currentVersion = currentTaskVersionRef.current.get(taskId) if (currentVersion && taskVersionIsAfter(currentVersion, eventVersion)) { if (terminalSnapshot) pendingTerminalProgressRef.current.delete(taskId) return false } if (event.event === 'progress' && ACTIVE_TASK_STATES.has(event.data.state)) { if (trustedActiveOverrideVersionsRef.current.get(taskId)?.updatedAt === eventVersion) streamActiveOverrideVersionsRef.current.delete(taskId) else streamActiveOverrideVersionsRef.current.set(taskId, eventVersion) } else streamActiveOverrideVersionsRef.current.delete(taskId) if (event.event === 'progress' && terminalSnapshot) { currentTaskVersionRef.current.set(taskId, eventVersion) taskProgressStore.set(taskId, event.data) pendingTerminalProgressRef.current.set(taskId, event) return true } const pendingTerminalProgress = event.event === 'terminal' ? pendingTerminalProgressRef.current.get(taskId) : undefined if (event.event === 'terminal') pendingTerminalProgressRef.current.delete(taskId) if (event.event === 'progress') taskProgressStore.set(taskId, event.data) else taskProgressStore.delete(taskId) const currentTaskState = currentTaskStateRef.current.get(taskId) currentTaskVersionRef.current.set(taskId, eventVersion) if (event.event === 'progress' && currentTaskState === event.data.state) return true currentTaskStateRef.current.set(taskId, event.data.state) setTaskOverrides((current) => { const previous = current[taskId] if ( event.event === 'progress' && previous?.updatedAt && taskVersionIsAfter(previous.updatedAt, event.data.updatedAt) ) return current return { ...current, [taskId]: event.event === 'progress' ? { errorCode: undefined, errorMessage: undefined, progressPercent: event.data.progressPercent, stage: event.data.stage, state: event.data.state, updatedAt: event.data.updatedAt, } : { errorCode: event.data.errorCode, errorMessage: undefined, ...(pendingTerminalProgress ? { progressPercent: pendingTerminalProgress.data.progressPercent, stage: pendingTerminalProgress.data.stage, } : {}), state: event.data.state, updatedAt: eventVersion, }, } }) if (event.event === 'terminal') { const pollGeneration = failedTaskPollGenerationsRef.current.get(taskId) ?? 0 failedTaskPollGenerationsRef.current.set(taskId, pollGeneration + 1) const timeout = terminalReconciliationTimeoutsRef.current.get(taskId) if (timeout !== undefined) window.clearTimeout(timeout) terminalReconciliationTimeoutsRef.current.delete(taskId) const reconciliationGeneration = (terminalReconciliationGenerationsRef.current.get(taskId) ?? 0) + 1 terminalReconciliationGenerationsRef.current.set(taskId, reconciliationGeneration) setTerminalTaskPins((current) => ({ ...current, [taskId]: { observedAt: eventVersion, taskListGeneration: taskListGenerationRef.current, }, })) if (event.data.state === 'failed') toast.error(t(($) => $['newKnowledge.taskFailedNotification'])) refreshDocuments() void reconcileTerminalTask(taskId, eventVersion, reconciliationGeneration) } return true }, [reconcileTerminalTask, refreshDocuments, t, taskProgressStore], ) const handleTaskUpdated = useCallback( (task: DocumentProcessingTask) => { const currentVersion = currentTaskVersionRef.current.get(task.id) if (currentVersion && taskVersionIsAfter(currentVersion, task.updatedAt)) return streamActiveOverrideVersionsRef.current.delete(task.id) auxiliaryTaskReadGuard.clearTask(task.id) failedPollAuxiliaryDenialsRef.current.delete(task.id) terminalConfirmableAuxiliaryDenialsRef.current.delete(task.id) if (taskIsActive(task)) trustedActiveOverrideVersionsRef.current.set(task.id, { taskListGeneration: taskListGenerationRef.current, updatedAt: task.updatedAt, }) else trustedActiveOverrideVersionsRef.current.delete(task.id) trustedOverrideListGenerationsRef.current.delete(task.id) taskProgressStore.delete(task.id) currentTaskStateRef.current.set(task.id, task.state) currentTaskVersionRef.current.set(task.id, task.updatedAt) // oxlint-disable-next-line eslint-react/set-state-in-effect -- A committed active list lifecycle retires the denied failed snapshot. setTaskOverrides((current) => ({ ...current, [task.id]: normalizedTaskSnapshot(task) })) if (taskIsActive(task)) { blockedFailedTaskPollVersionsRef.current.delete(task.id) taskEventCursorsRef.current.delete(task.id) terminalReconciliationControllersRef.current.get(task.id)?.abort() terminalReconciliationControllersRef.current.delete(task.id) const timeout = terminalReconciliationTimeoutsRef.current.get(task.id) if (timeout !== undefined) window.clearTimeout(timeout) terminalReconciliationTimeoutsRef.current.delete(task.id) const generation = terminalReconciliationGenerationsRef.current.get(task.id) ?? 0 terminalReconciliationGenerationsRef.current.set(task.id, generation + 1) const pollGeneration = failedTaskPollGenerationsRef.current.get(task.id) ?? 0 failedTaskPollGenerationsRef.current.set(task.id, pollGeneration + 1) pendingTerminalProgressRef.current.delete(task.id) // oxlint-disable-next-line eslint-react/set-state-in-effect -- The authoritative active lifecycle supersedes the terminal pin. setTerminalTaskPins((current) => { const next = { ...current } delete next[task.id] return next }) } }, [auxiliaryTaskReadGuard, taskProgressStore], ) useEffect(() => { if (permissionDenied) return for (const task of baseTasks) { if (!taskIsActive(task)) continue const denial = failedPollAuxiliaryDenialsRef.current.get(task.id) if (!denial || taskListGeneration <= denial.taskListGeneration) continue if (taskVersionIsAfter(denial.taskVersion, task.updatedAt)) continue failedPollAuxiliaryDenialsRef.current.delete(task.id) handleTaskUpdated(task) } }, [baseTasks, handleTaskUpdated, permissionDenied, taskListGeneration]) useEffect(() => { for (const task of activeTasks) { const hadBlockedPoll = blockedFailedTaskPollVersionsRef.current.delete(task.id) if (!hadBlockedPoll) continue const generation = failedTaskPollGenerationsRef.current.get(task.id) ?? 0 failedTaskPollGenerationsRef.current.set(task.id, generation + 1) } }, [activeTasks]) useEffect(() => { if (permissionDenied || !orderedFailedTasksRef.current.length) return let canceled = false let timeout: number | undefined const cancelRequests = new Set<() => void>() const pollNextBatch = async () => { const failedTasks = orderedFailedTasksRef.current const pollableTasks = failedTasks.filter( (task) => blockedFailedTaskPollVersionsRef.current.get(task.id) !== task.updatedAt && !auxiliaryTaskReadGuard.isBlocked(task.id, task.updatedAt), ) if (pollableTasks.length) { const pollCount = Math.min(MAX_TASK_EVENT_STREAMS, pollableTasks.length) const offset = failedTaskPollOffsetRef.current % pollableTasks.length const tasksToPoll = Array.from( { length: pollCount }, (_, index) => pollableTasks[(offset + index) % pollableTasks.length]!, ) failedTaskPollOffsetRef.current += MAX_TASK_EVENT_STREAMS await Promise.allSettled( tasksToPoll.map(async (task) => { const requestGeneration = (failedTaskPollGenerationsRef.current.get(task.id) ?? 0) + 1 failedTaskPollGenerationsRef.current.set(task.id, requestGeneration) const requestController = new AbortController() let requestTimeout: number | undefined let rejectDeadline: ((reason?: unknown) => void) | undefined const cancelRequest = () => { requestController.abort() rejectDeadline?.(new DOMException('Task snapshot request aborted', 'AbortError')) } try { const request = consoleClient.knowledgeFs.getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId( { params: { documentId: task.documentId, id: knowledgeSpaceId, taskId: task.id, }, }, { signal: requestController.signal }, ) const deadline = new Promise((_resolve, reject) => { rejectDeadline = reject requestTimeout = window.setTimeout(() => { requestController.abort() reject(new DOMException('Task snapshot request timed out', 'TimeoutError')) }, FAILED_TASK_POLL_REQUEST_TIMEOUT) }) cancelRequests.add(cancelRequest) const snapshot = await Promise.race([request, deadline]) if ( canceled || failedTaskPollGenerationsRef.current.get(task.id) !== requestGeneration ) return const currentVersion = currentTaskVersionRef.current.get(task.id) if (currentVersion && taskVersionIsAfter(currentVersion, snapshot.updatedAt)) return handleTaskUpdated(snapshot) } catch (error) { if ( canceled || failedTaskPollGenerationsRef.current.get(task.id) !== requestGeneration ) return if (responseStatus(error) === 403) { const currentTaskVersion = currentTaskVersionRef.current.get(task.id) const deniedVersion = currentTaskVersion && taskVersionIsAfter(currentTaskVersion, task.updatedAt) ? currentTaskVersion : task.updatedAt failedPollAuxiliaryDenialsRef.current.set(task.id, { taskListGeneration: taskListGenerationRef.current, taskVersion: deniedVersion, }) denyAuxiliaryTaskRead(task.id, deniedVersion) return } if (!taskSnapshotErrorIsTransient(error)) blockedFailedTaskPollVersionsRef.current.set(task.id, task.updatedAt) } finally { if (requestTimeout !== undefined) window.clearTimeout(requestTimeout) cancelRequests.delete(cancelRequest) } }), ) } if (!canceled) timeout = window.setTimeout(() => void pollNextBatch(), 5000) } timeout = window.setTimeout(() => void pollNextBatch(), 5000) return () => { canceled = true for (const cancelRequest of cancelRequests) cancelRequest() cancelRequests.clear() if (timeout !== undefined) window.clearTimeout(timeout) } }, [ auxiliaryTaskReadGuard, denyAuxiliaryTaskRead, failedTaskPollSignature, handleTaskUpdated, knowledgeSpaceId, permissionDenied, ]) const toggleDocument = useCallback( (documentId: string) => { if (!canWrite || selectionDisabled) return setSelectedDocumentIds((current) => { const next = new Set(current) if (next.has(documentId)) next.delete(documentId) else next.add(documentId) return next }) }, [canWrite, selectionDisabled], ) const toggleAllFiltered = () => { if (!canWrite || selectionDisabled) return setSelectedDocumentIds((current) => { const next = new Set(current) if (allFilteredSelected) selectableFilteredDocuments.forEach((document) => next.delete(document.id)) else selectableFilteredDocuments.forEach((document) => next.add(document.id)) return next }) } const retryDependencyQueries = () => { if (taskQueryBlockingError || sourceQueryBlockingError) setBlockingDependencyRetries((current) => ({ sources: current.sources || sourceQueryBlockingError, tasks: current.tasks || taskQueryBlockingError, })) if (tasksQuery.isFetchNextPageError) void tasksQuery.fetchNextPage() else if (tasksQuery.error || taskQueryBlockingError) void tasksQuery.refetch() if (sourcesQuery.isFetchNextPageError) void sourcesQuery.fetchNextPage() else if (sourcesQuery.error || sourceQueryBlockingError) void sourcesQuery.refetch() } const loadMoreResults = () => { const requests: Promise[] = [] if (hasNextDocumentPage && !isFetchingNextDocumentPage) requests.push(fetchNextDocumentPage()) if (hasNextTaskPage && !isFetchingNextTaskPage) requests.push(fetchNextTaskPage()) if (hasRelevantNextSourcePage && !isFetchingNextSourcePage) requests.push(fetchNextSourcePage()) void Promise.allSettled(requests) } const handleTaskEventCursor = useCallback((taskId: string, eventId?: string) => { if (eventId) taskEventCursorsRef.current.set(taskId, eventId) else taskEventCursorsRef.current.delete(taskId) }, []) const handleTaskStreamPermissionDenied = useCallback( (taskId: string, taskVersion: string) => { terminalConfirmableAuxiliaryDenialsRef.current.set(taskId, { taskListGeneration: taskListGenerationRef.current, taskVersion, }) denyAuxiliaryTaskRead(taskId, taskVersion) }, [denyAuxiliaryTaskRead], ) return ( <> {streamedActiveTasks.map((task) => { const observerVersion = taskObserverVersion(task) return ( ) })} {canWrite && ( $['newKnowledge.uploadDocuments'])} tabIndex={-1} type="file" onChange={(event) => { const files = [...(event.currentTarget.files ?? [])] event.currentTarget.value = '' void handleUploadFiles(files) }} /> )}
{ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { documentSurfaceHadFocusRef.current = false if (event.relatedTarget && event.relatedTarget !== uploadInputRef.current) { writePermissionFocusRecoveryRequestedRef.current = false writePermissionFocusOriginRef.current = null } } }} onFocusCapture={(event) => { if ( writePermissionFocusRecoveryRequestedRef.current && event.target !== writePermissionFocusOriginRef.current ) { writePermissionFocusRecoveryRequestedRef.current = false writePermissionFocusOriginRef.current = null } documentSurfaceHadFocusRef.current = true }} >

{t(($) => $['newKnowledge.documents'])}

{t(($) => $['newKnowledge.documentsDescription'])}

{permissionPending && (

{t(($) => $['newKnowledge.permission'])} {' · '} {tCommon(($) => $.loading)}

)} {!permissionPending && !permissionQueryError && (!canEdit || writePermissionRevoked) && (

{t(($) => $['newKnowledge.documentPermissionRestricted'])}

)}
{permissionQueryError && (
{t(($) => $['newKnowledge.permissionLoadFailed'])}
)} {documentsQuery.error && documentsQuery.data && !permissionDenied && !documentsQuery.isFetchNextPageError && (
{t(($) => responseStatus(documentsQuery.error) === 403 ? $['newKnowledge.documentsPermissionDescription'] : $['newKnowledge.documentsErrorDescription'], )} {responseStatus(documentsQuery.error) !== 403 && ( )}
)} {!permissionDenied && !dependencyQueryBlockingError && dependencyQueryWarning && (
{sourcesQuery.error || sourcesQuery.isFetchNextPageError ? t(($) => $['newKnowledge.sourcesErrorDescription']) : t(($) => $['newKnowledge.tasksErrorDescription'])}
)} {documentsQuery.isPending && !permissionDenied ? (
) : permissionDenied || (documentsQuery.error && !documentsQuery.data) ? (

{t(($) => permissionDenied ? $['newKnowledge.documentsPermissionTitle'] : $['newKnowledge.documentsErrorTitle'], )}

{t(($) => permissionDenied ? $['newKnowledge.documentsPermissionDescription'] : $['newKnowledge.documentsErrorDescription'], )}

{(!permissionDenied || (auxiliaryReadPermissionDenied && !documentPermissionDenied)) && ( )}
) : dependencyQueryBlockingError ? (

{taskQueryBlockingError ? t(($) => $['newKnowledge.tasksErrorDescription']) : t(($) => $['newKnowledge.sourcesErrorDescription'])}

) : !documents.length ? ( { writePermissionFocusRecoveryRequestedRef.current = true writePermissionFocusOriginRef.current = document.activeElement as HTMLElement | null uploadInputRef.current?.click() }} onDropFiles={(files) => void handleUploadFiles(files)} onOpenTasks={() => setTasksOpen(true)} readOnlyReasonId={documentWriteRestrictionReasonId} tasksButtonLabel={tasksButtonLabel} tasksLiveStatus={tasksLiveStatus} uploading={uploading} /> ) : ( newKnowledgeDocumentDetailPath(knowledgeSpaceId, documentId) } hasNextPage={Boolean( hasNextDocumentPage || hasNextTaskPage || hasRelevantNextSourcePage, )} hasSelectableDocuments={Boolean(selectableFilteredDocuments.length)} hasTaskError={hasTaskError} isFetchNextPageError={documentsQuery.isFetchNextPageError} isFetchingNextDocumentPage={isFetchingNextDocumentPage} isFetchingNextPage={isFetchingNextResultsPage} onAddDocument={() => { writePermissionFocusRecoveryRequestedRef.current = true writePermissionFocusOriginRef.current = document.activeElement as HTMLElement | null uploadInputRef.current?.click() }} onFilterChange={setFilter} onLoadMore={loadMoreResults} onOpenTasks={() => setTasksOpen(true)} onSearchChange={setSearch} onSelectAll={toggleAllFiltered} onSelectDocument={toggleDocument} readOnlyReasonId={documentWriteRestrictionReasonId} resultsIncomplete={filteredResultsIncomplete} search={search} selectionDisabled={selectionDisabled} selectedDocumentIds={validSelectedDocumentIds} someSelected={someFilteredSelected} sourcesPending={sourceResultsIncomplete} sourceNames={sourceNames} statusPending={dependencyResultsIncomplete} statuses={documentStatuses} tasksPending={taskResultsIncomplete} tasksButtonLabel={tasksButtonLabel} tasksLiveStatus={tasksLiveStatus} uploading={uploading} /> )}
{bulkActionsVisible && ( setSelectedDocumentIds(new Set())} onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) bulkActionsHadFocusRef.current = false }} onFocusCapture={() => { bulkActionsHadFocusRef.current = true }} onReindex={() => void handleReindexDocuments()} reindexing={reindexing} selectedCount={validSelectedDocumentIds.size} /> )} 0} isFetchingNextDocumentPage={isFetchingNextDocumentPage} isFetchingNextTaskPage={isFetchingNextTaskPage} knowledgeSpaceId={knowledgeSpaceId} onLoadMoreDocuments={() => void fetchNextDocumentPage()} onLoadMoreTasks={() => void fetchNextTaskPage()} onOpenChange={setTasksOpen} onRefreshDocumentsAndTasks={refreshDocumentsAndTasks} onRetryPermissionQuery={() => void refreshWorkspacePermissions(true)} onRetryDocumentQuery={() => { if (documentsQuery.isFetchNextPageError) void documentsQuery.fetchNextPage() else void refetchDocumentsQuery() }} onRetryTaskQuery={() => { if (tasksQuery.isFetchNextPageError) void tasksQuery.fetchNextPage() else void tasksQuery.refetch() }} onTaskUpdated={handleTaskUpdated} onWritePermissionDenied={handleWritePermissionDenied} open={tasksOpen && !permissionDenied} permissionQueryError={permissionQueryError} permissionQueryFetching={workspacePermissionKeysFetching} permissionQueryPending={permissionPending} readOnlyReason={ writePermissionRevoked || (!permissionPending && !permissionQueryError && !canEdit) ? t(($) => $['newKnowledge.documentPermissionRestricted']) : undefined } taskQueryPending={tasksQuery.isPending} taskQueryError={Boolean(tasksQuery.error || tasksQuery.isFetchNextPageError)} taskQueryFetching={tasksQuery.isFetching} taskProgressStore={taskProgressStore} tasks={tasks} /> ) }