diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 4aa4ad52ac7..870cc069692 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -1,4 +1,5 @@ export { isResourceListEmpty } from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty' +export { ResourceNotFound } from '@/app/workspace/[workspaceId]/components/resource/resource-not-found' export { ConversationListItem } from './conversation-list-item' export type { ErrorBoundaryProps, ErrorStateProps } from './error' export { ErrorShell, ErrorState } from './error' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource-not-found.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource-not-found.tsx new file mode 100644 index 00000000000..fa6007c8e02 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource-not-found.tsx @@ -0,0 +1,27 @@ +import type { ComponentType } from 'react' + +interface ResourceNotFoundProps { + icon: ComponentType<{ className?: string }> + title: string + description: string +} + +/** + * Full-page screen for a resource that could not be loaded and has no shell left to + * draw — a knowledge base or a document that was deleted or moved. + * + * Distinct from the `emptyState` slot on {@link Resource.Table}: that one keeps the + * chrome and reports a failure *within* a page that still exists. This replaces the + * page, so it is only right when the thing the page is about is the thing that is gone. + */ +export function ResourceNotFound({ icon: Icon, title, description }: ResourceNotFoundProps) { + return ( +
+ +
+

{title}

+

{description}

+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 5c9e0e4d99d..3f2d482dbe0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -7,15 +7,19 @@ import { ChevronUp, Database, FileText, + FileX, Pencil, Plus, TagIcon, Trash, + TriangleAlert, } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' +import { EmptyState } from '@/components/empty-state/empty-state' import type { ChunkData } from '@/lib/knowledge/types' import { formatTokenCount } from '@/lib/tokenization' import type { @@ -29,7 +33,11 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { EMPTY_CELL_PLACEHOLDER, Resource } from '@/app/workspace/[workspaceId]/components' +import { + EMPTY_CELL_PLACEHOLDER, + Resource, + ResourceNotFound, +} from '@/app/workspace/[workspaceId]/components' import { FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, @@ -138,6 +146,41 @@ const CHUNK_COLUMNS: ResourceColumn[] = [ { id: 'status', header: 'Status', widthMultiplier: 0.75 }, ] +/** Stable identity for the error branch's empty row set. */ +const EMPTY_CHUNK_ROWS: ResourceRow[] = [] + +/** Longer than this and a server message pushes the frame taller than the table body. */ +const ERROR_MESSAGE_MAX_LENGTH = 160 + +interface ChunkLoadErrorProps { + message: string + /** + * Which read failed. A failed search leaves the loaded chunks intact, so saying the + * chunks could not be loaded would be untrue — it is the search that did not run. + */ + kind: 'load' | 'search' +} + +/** + * A failed read, drawn where the rows would have been. + * + * The table keeps its chrome and its controls — the headers still render, and the search + * box stays so a search that triggered the failure can be cleared. Only the body says + * what went wrong, instead of the page going silently blank. + * + * Tinted with the error token: at the muted weight the other empty states use, a failure + * is indistinguishable from "nothing here yet", which is the confusion this exists to end. + */ +function ChunkLoadError({ message, kind }: ChunkLoadErrorProps) { + return ( + } + title={kind === 'search' ? 'Search failed' : "Couldn't load chunks"} + description={truncate(message, ERROR_MESSAGE_MAX_LENGTH)} + /> + ) +} + export function Document({ knowledgeBaseId, documentId, @@ -242,7 +285,7 @@ export function Document({ } ) - const searchError = searchQueryError instanceof Error ? searchQueryError.message : null + const searchError = searchQueryError ? getErrorMessage(searchQueryError) : null const [selectedChunks, setSelectedChunks] = useState>(() => new Set()) @@ -334,8 +377,6 @@ export function Document({ closeMenu: closeContextMenu, } = useContextMenu() - const combinedError = documentError || searchError || initialError - const isConnectorDocument = Boolean(documentData?.connectorId) const effectiveDocumentName = documentData?.filename || documentName || 'Document' /** @@ -351,6 +392,17 @@ export function Document({ const DocumentIcon = ConnectorIcon || getDocumentIcon(documentData?.mimeType ?? '', effectiveDocumentName) const isCompleted = documentData?.processingStatus === 'completed' + + /** + * Kept separate from `documentError`: without the document there is no page to draw, + * while a failed chunk read still has one to frame it. + * + * A document that is not `completed` is excluded, because the chunk read rejects for + * those by design — `requireChunkReadable` throws `KnowledgeDocumentNotReadyError` + * before it queries anything. That is the document's state, not a failure, and + * `chunkRows` already renders a row saying which state it is in. + */ + const chunkError = isCompleted ? initialError || searchError : null const canEdit = userPermissions.canEdit === true const isInEditorView = selectedChunkId !== null || isCreatingNewChunk @@ -533,7 +585,7 @@ export function Document({ /** * `Knowledge Base / …the base's folders / / `. Every view on this route is that - * trail with a different last crumb — the document, a chunk, an error, a loading placeholder + * trail with a different last crumb — the document, a chunk, a loading placeholder * — so it is built once here rather than restated per view. */ const documentTrail = useCallback( @@ -572,35 +624,30 @@ export function Document({ const breadcrumbs = useMemo( () => - documentTrail( - combinedError - ? { label: 'Error', terminal: true } - : { - label: documentCrumbLabel, - icon: DocumentIcon, - editing: docRename.editingId - ? { - isEditing: true, - value: docRename.editValue, - onChange: docRename.setEditValue, - onSubmit: docRename.submitRename, - onCancel: docRename.cancelRename, - disabled: docRename.isSaving, - } - : undefined, - dropdownItems: [ - ...(userPermissions.canEdit - ? [ - { label: 'Rename', icon: Pencil, onClick: handleStartDocRename }, - { label: 'Tags', icon: TagIcon, onClick: handleShowTags }, - { label: 'Delete', icon: Trash, onClick: handleShowDeleteDoc }, - ] - : []), - ], + documentTrail({ + label: documentCrumbLabel, + icon: DocumentIcon, + editing: docRename.editingId + ? { + isEditing: true, + value: docRename.editValue, + onChange: docRename.setEditValue, + onSubmit: docRename.submitRename, + onCancel: docRename.cancelRename, + disabled: docRename.isSaving, } - ), + : undefined, + dropdownItems: [ + ...(userPermissions.canEdit + ? [ + { label: 'Rename', icon: Pencil, onClick: handleStartDocRename }, + { label: 'Tags', icon: TagIcon, onClick: handleShowTags }, + { label: 'Delete', icon: Trash, onClick: handleShowDeleteDoc }, + ] + : []), + ], + }), [ - combinedError, documentTrail, documentCrumbLabel, DocumentIcon, @@ -914,6 +961,12 @@ export function Document({ } : undefined + /** + * A failed read paged nothing, so the bar would be counting pages that were never + * fetched. Read by the table and by the action bar's offset, which has to agree. + */ + const tablePagination = chunkError ? undefined : paginationConfig + const sortConfig: SortConfig = useMemo( () => ({ options: [ @@ -1099,6 +1152,21 @@ export function Document({ saveStatus, ]) + /** + * Ahead of the editor branches on purpose — `selectedChunkId` renders the chunk editor + * without checking for a document, so a document that failed to load has to + * short-circuit before it. Mirrors the base page's 'not found' screen one level up. + */ + if (documentError && !documentData) { + return ( + + ) + } + if (isCreatingNewChunk && documentData) { return ( <> @@ -1191,18 +1259,23 @@ export function Document({ ]} /> + ) : undefined + } + selectable={chunkError ? undefined : selectableConfig} onRowClick={isCompleted ? handleChunkClick : undefined} onRowContextMenu={isCompleted ? handleChunkContextMenu : undefined} - pagination={paginationConfig} + pagination={tablePagination} /> @@ -1223,7 +1296,7 @@ export function Document({ /> 0 && !isConnectorDocument ? handleBulkEnable : undefined} onDisable={enabledCount > 0 && !isConnectorDocument ? handleBulkDisable : undefined} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 7f963ea85c8..5164b2dde68 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -63,6 +63,7 @@ import { FloatingOverflowText, isResourceListEmpty, Resource, + ResourceNotFound, } from '@/app/workspace/[workspaceId]/components' import { FOLDERED_RESOURCE_HEADERS, @@ -1258,15 +1259,11 @@ export function KnowledgeBase({ if (error && !knowledgeBase) { return ( -
- -
-

Knowledge base not found

-

- This knowledge base may have been deleted or moved -

-
-
+ ) }