dify/web/features/new-rag/documents-page.tsx
Stephen Zhou 2d5161a647
feat(dataset): add document detail (#39361)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-24 15:27:35 +00:00

2231 lines
92 KiB
TypeScript

'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<HTMLInputElement>(null)
const documentPermissionAlertRef = useRef<HTMLDivElement>(null)
const writePermissionFocusRecoveryRequestedRef = useRef(false)
const writePermissionFocusOriginRef = useRef<HTMLElement | null>(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<Set<string>>(() => 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<string, Partial<DocumentProcessingTask>>
>({})
const [terminalTaskPins, setTerminalTaskPins] = useState<Record<string, TerminalTaskPin>>({})
const [taskObserverGenerations, setTaskObserverGenerations] = useState<Record<string, number>>({})
const terminalReconciliationGenerationsRef = useRef(new Map<string, number>())
const failedTaskPollGenerationsRef = useRef(new Map<string, number>())
const blockedFailedTaskPollVersionsRef = useRef(new Map<string, string>())
const failedPollAuxiliaryDenialsRef = useRef(new Map<string, AuxiliaryTaskReadDenial>())
const terminalConfirmableAuxiliaryDenialsRef = useRef(new Map<string, AuxiliaryTaskReadDenial>())
const equalRetryListGenerationsRef = useRef(new Map<string, number>())
const terminalReconciliationTimeoutsRef = useRef(new Map<string, number>())
const terminalReconciliationControllersRef = useRef(new Map<string, AbortController>())
const pendingTerminalProgressRef = useRef(new Map<string, ProcessingTaskProgressEvent>())
const taskEventCursorsRef = useRef(new Map<string, string>())
const streamActiveOverrideVersionsRef = useRef(new Map<string, string>())
const trustedActiveOverrideVersionsRef = useRef(new Map<string, TrustedActiveOverride>())
const trustedOverrideListGenerationsRef = useRef(new Map<string, number>())
const taskProgressStoreRef = useRef<ReturnType<typeof createTaskProgressStore> | 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<HTMLElement>(null)
const documentsTitleRef = useRef<HTMLHeadingElement>(null)
const documentSurfaceHadFocusRef = useRef(false)
const bulkActionsHadFocusRef = useRef(false)
const mainRetryFocusRequestedRef = useRef(false)
const permissionRetryButtonRef = useRef<HTMLButtonElement>(null)
const documentsRetryButtonRef = useRef<HTMLButtonElement>(null)
const dependencyRetryButtonRef = useRef<HTMLButtonElement>(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<string, unknown>) => {
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 = <Value,>(current: Record<string, Value>) => {
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<string, DocumentProcessingTask>()
const equalTimestampRetries = new Map<string, TerminalTaskPin>()
const confirmedTerminals = new Map<string, DocumentProcessingTask>()
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<never>((_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<unknown>[] = []
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 (
<TaskEventObserver
key={`${task.id}:${taskObserverGenerations[task.id] ?? 0}`}
documentId={task.documentId}
knowledgeSpaceId={knowledgeSpaceId}
lastEventId={taskEventCursorsRef.current.get(task.id)}
onEvent={handleTaskEvent}
onLastEventIdChange={handleTaskEventCursor}
onPermissionDenied={handleTaskStreamPermissionDenied}
taskId={task.id}
taskVersion={observerVersion}
/>
)
})}
{canWrite && (
<input
ref={uploadInputRef}
multiple
hidden
accept={DOCUMENT_UPLOAD_ACCEPT}
aria-label={t(($) => $['newKnowledge.uploadDocuments'])}
tabIndex={-1}
type="file"
onChange={(event) => {
const files = [...(event.currentTarget.files ?? [])]
event.currentTarget.value = ''
void handleUploadFiles(files)
}}
/>
)}
<section
ref={documentsSectionRef}
className={cn(
'flex min-h-full flex-col px-4 pt-6 sm:px-8 sm:pt-7',
bulkActionsVisible ? 'pb-[calc(7rem+env(safe-area-inset-bottom,0px))]' : 'pb-6 sm:pb-7',
)}
onBlurCapture={(event) => {
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
}}
>
<header>
<h2
ref={documentsTitleRef}
id="new-knowledge-documents-title"
className="title-xl-semi-bold text-text-primary"
tabIndex={-1}
>
{t(($) => $['newKnowledge.documents'])}
</h2>
<p className="mt-1 system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.documentsDescription'])}
</p>
{permissionPending && (
<p
id="documents-permission-pending"
className="mt-2 system-xs-regular text-text-tertiary"
role="status"
>
{t(($) => $['newKnowledge.permission'])}
{' · '}
{tCommon(($) => $.loading)}
</p>
)}
{!permissionPending && !permissionQueryError && (!canEdit || writePermissionRevoked) && (
<p
id="documents-readonly-reason"
className="mt-2 inline-flex items-center gap-1.5 system-xs-regular text-text-warning"
role="status"
>
<span aria-hidden className="i-ri-lock-line size-3.5" />
{t(($) => $['newKnowledge.documentPermissionRestricted'])}
</p>
)}
</header>
{permissionQueryError && (
<div
className="mt-4 flex items-center justify-between gap-3 rounded-lg border border-divider-regular bg-background-section px-3 py-2"
role="alert"
>
<span id="documents-permission-error" className="system-xs-regular text-text-tertiary">
{t(($) => $['newKnowledge.permissionLoadFailed'])}
</span>
<Button
ref={permissionRetryButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.permissionLoadFailed'])}`}
aria-busy={workspacePermissionKeysFetching}
loading={workspacePermissionKeysFetching}
size="small"
onBlur={(event) => {
if (event.relatedTarget) mainRetryFocusRequestedRef.current = false
}}
onClick={() => {
mainRetryFocusRequestedRef.current = true
void refreshWorkspacePermissions(true)
}}
>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
)}
{documentsQuery.error &&
documentsQuery.data &&
!permissionDenied &&
!documentsQuery.isFetchNextPageError && (
<div
className="mt-4 flex items-center justify-between gap-3 rounded-lg border border-divider-regular bg-background-section px-3 py-2"
role="alert"
>
<span className="system-xs-regular text-text-tertiary">
{t(($) =>
responseStatus(documentsQuery.error) === 403
? $['newKnowledge.documentsPermissionDescription']
: $['newKnowledge.documentsErrorDescription'],
)}
</span>
{responseStatus(documentsQuery.error) !== 403 && (
<Button
ref={documentsRetryButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${t(($) => $['newKnowledge.documentsErrorDescription'])}`}
aria-busy={documentsQuery.isRefetching}
loading={documentsQuery.isRefetching}
size="small"
onBlur={(event) => {
if (event.relatedTarget) mainRetryFocusRequestedRef.current = false
}}
onClick={() => {
mainRetryFocusRequestedRef.current = true
void refetchDocumentsQuery()
}}
>
{tCommon(($) => $['operation.retry'])}
</Button>
)}
</div>
)}
{!permissionDenied && !dependencyQueryBlockingError && dependencyQueryWarning && (
<div
className="mt-4 flex items-center justify-between gap-3 rounded-lg border border-divider-regular bg-background-section px-3 py-2"
role="alert"
>
<span className="system-xs-regular text-text-tertiary">
{sourcesQuery.error || sourcesQuery.isFetchNextPageError
? t(($) => $['newKnowledge.sourcesErrorDescription'])
: t(($) => $['newKnowledge.tasksErrorDescription'])}
</span>
<Button
ref={dependencyRetryButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${
sourcesQuery.error || sourcesQuery.isFetchNextPageError
? t(($) => $['newKnowledge.sourcesErrorDescription'])
: t(($) => $['newKnowledge.tasksErrorDescription'])
}`}
aria-busy={dependencyRetryFetching}
loading={dependencyRetryFetching}
size="small"
onBlur={(event) => {
if (event.relatedTarget) mainRetryFocusRequestedRef.current = false
}}
onClick={() => {
mainRetryFocusRequestedRef.current = true
retryDependencyQueries()
}}
>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
)}
{documentsQuery.isPending && !permissionDenied ? (
<div className="flex min-h-64 flex-1 items-center justify-center">
<Loading />
</div>
) : permissionDenied || (documentsQuery.error && !documentsQuery.data) ? (
<div
ref={documentPermissionAlertRef}
className="flex min-h-64 flex-1 flex-col items-center justify-center px-6 text-center"
role="alert"
tabIndex={permissionDenied ? -1 : undefined}
>
<span aria-hidden className="i-ri-error-warning-line size-7 text-text-tertiary" />
<h2 className="mt-3 title-xl-semi-bold text-text-primary">
{t(($) =>
permissionDenied
? $['newKnowledge.documentsPermissionTitle']
: $['newKnowledge.documentsErrorTitle'],
)}
</h2>
<p className="mt-2 max-w-md body-sm-regular text-text-tertiary">
{t(($) =>
permissionDenied
? $['newKnowledge.documentsPermissionDescription']
: $['newKnowledge.documentsErrorDescription'],
)}
</p>
{(!permissionDenied ||
(auxiliaryReadPermissionDenied && !documentPermissionDenied)) && (
<Button
ref={documentsRetryButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${documentsRecoveryDescription}`}
aria-busy={documentsQuery.isFetching}
className="mt-4"
loading={documentsQuery.isFetching}
onBlur={(event) => {
if (event.relatedTarget) mainRetryFocusRequestedRef.current = false
}}
onClick={() => {
mainRetryFocusRequestedRef.current = true
if (auxiliaryReadPermissionDenied) retryAuxiliaryTaskRead()
else void refetchDocumentsQuery()
}}
>
{tCommon(($) => $['operation.retry'])}
</Button>
)}
</div>
) : dependencyQueryBlockingError ? (
<div
className="flex min-h-64 flex-1 flex-col items-center justify-center px-6 text-center"
role="alert"
>
<span aria-hidden className="i-ri-error-warning-line size-7 text-text-tertiary" />
<p className="mt-3 max-w-md body-sm-regular text-text-tertiary">
{taskQueryBlockingError
? t(($) => $['newKnowledge.tasksErrorDescription'])
: t(($) => $['newKnowledge.sourcesErrorDescription'])}
</p>
<Button
ref={dependencyRetryButtonRef}
aria-label={`${tCommon(($) => $['operation.retry'])} · ${
taskQueryBlockingError
? t(($) => $['newKnowledge.tasksErrorDescription'])
: t(($) => $['newKnowledge.sourcesErrorDescription'])
}`}
aria-busy={blockingDependencyRetryFetching}
className="mt-4"
loading={blockingDependencyRetryFetching}
onBlur={(event) => {
if (event.relatedTarget) mainRetryFocusRequestedRef.current = false
}}
onClick={() => {
mainRetryFocusRequestedRef.current = true
retryDependencyQueries()
}}
>
{tCommon(($) => $['operation.retry'])}
</Button>
</div>
) : !documents.length ? (
<DocumentsEmpty
activeTaskCount={activeTasks.length}
attentionTaskBadge={attentionTaskBadge}
canEdit={canWrite}
hasTaskError={hasTaskError}
onAddDocument={() => {
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}
/>
) : (
<DocumentsList
activeTaskCount={activeTasks.length}
allSelected={allFilteredSelected}
attentionTaskBadge={attentionTaskBadge}
canEdit={canWrite}
completingResults={completingFilteredResults}
documents={filteredDocuments}
filter={filter}
getDocumentHref={(documentId) =>
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}
/>
)}
</section>
{bulkActionsVisible && (
<DocumentBulkActions
disabled={selectionDisabled}
disabledReason={reindexUnavailableReason}
onClear={() => 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}
/>
)}
<ProcessingTasksDrawer
actionResultsValid={!permissionDenied}
canEdit={canWrite}
documentQueryError={Boolean(documentsQuery.error || documentsQuery.isFetchNextPageError)}
documentQueryFetching={documentsQuery.isFetching}
documents={documents}
documentsPending={Boolean(hasNextDocumentPage || documentsQuery.isFetchingNextPage)}
hasNextDocumentPage={Boolean(hasNextDocumentPage)}
hasNextTaskPage={Boolean(hasNextTaskPage)}
hasUnresolvedTaskDocuments={unresolvedTaskDocumentIds.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}
/>
</>
)
}