From c74d27c444824f8f3edc331c7ce4440bdd7f69dd Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 19 Aug 2026 12:58:06 -0700 Subject: [PATCH 1/2] fix(knowledge): say what went wrong when a document's chunks fail to load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `combinedError = documentError || searchError || initialError` collapsed three different failures into one, blanked the rows and stripped search, sort and filter — and said nothing. A failed read rendered as an empty table, which is the same thing the page shows when a document genuinely has no chunks. Stripping the search box was the worse half: when it was the *search* that failed, the control the user needed to clear it was the one that disappeared. The three are now told apart: - The document itself failing has no page left to draw, so it gets a full screen, matching the base page's 'Knowledge base not found' one level up. It has to run before the editor branches — `selectedChunkId` renders the chunk editor without checking for a document, so a deep link to a chunk of a deleted document sat on 'Loading chunk…' forever. - A failed chunk read keeps the document, so it keeps the chrome and the controls, and the message goes in the table body through the `emptyState` slot. Tinted with the error token, because at the weight the empty states use a failure is indistinguishable from 'nothing here yet'. - A failed search leaves the loaded chunks intact, so it says the search failed rather than claiming the chunks could not be loaded. `searchError` went through `instanceof Error ? .message : null`, so a rejection that was not an `Error` produced no message and fell back to the silent blank this commit exists to remove. It uses `getErrorMessage` now, like the chunk read beside it always did. Pagination is dropped on a failed read — it was counting pages nothing fetched — and the action bar reads the same value, so it no longer lifts itself clear of a bar that is not there. The not-found screen was about to be copied a second time, so it moves to `ResourceNotFound` and the base page adopts it. --- .../[workspaceId]/components/index.ts | 1 + .../resource/resource-not-found.tsx | 27 ++++ .../knowledge/[id]/[documentId]/document.tsx | 146 +++++++++++++----- .../[workspaceId]/knowledge/[id]/base.tsx | 15 +- 4 files changed, 141 insertions(+), 48 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/resource-not-found.tsx 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..a8eb37e03f3 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,7 +377,11 @@ export function Document({ closeMenu: closeContextMenu, } = useContextMenu() - const combinedError = documentError || searchError || initialError + /** + * Kept separate from `documentError`: without the document there is no page to draw, + * while a failed chunk read still has one to frame it. + */ + const chunkError = initialError || searchError const isConnectorDocument = Boolean(documentData?.connectorId) const effectiveDocumentName = documentData?.filename || documentName || 'Document' @@ -533,7 +580,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 +619,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 +956,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 +1147,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 +1254,23 @@ export function Document({ ]} /> + ) : undefined + } + selectable={chunkError ? undefined : selectableConfig} onRowClick={isCompleted ? handleChunkClick : undefined} onRowContextMenu={isCompleted ? handleChunkContextMenu : undefined} - pagination={paginationConfig} + pagination={tablePagination} /> @@ -1223,7 +1291,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 -

-
-
+ ) } From 8a4fc0c10d9e88668336cc48fb1253be1e9f3ebb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 19 Aug 2026 13:06:57 -0700 Subject: [PATCH 2/2] fix(knowledge): let a processing document say so, not that it failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document that is not `completed` rejects the chunk read by design — `requireChunkReadable` throws `KnowledgeDocumentNotReadyError` before it queries anything — so `initialError` is set for every pending, processing or failed document. Treating that as a load failure put "Couldn't load chunks" over a document that is simply still working. `chunkRows` already builds the right row for those states, and it turns out nothing could ever see it: the old `combinedError` blanked the rows on exactly the same condition, so "Document processing pending..." has been unreachable for as long as it has existed. Excluding not-ready documents from `chunkError` brings the row back and leaves the error state for reads that genuinely failed. --- .../knowledge/[id]/[documentId]/document.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) 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 a8eb37e03f3..3f2d482dbe0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -377,12 +377,6 @@ export function Document({ closeMenu: closeContextMenu, } = useContextMenu() - /** - * Kept separate from `documentError`: without the document there is no page to draw, - * while a failed chunk read still has one to frame it. - */ - const chunkError = initialError || searchError - const isConnectorDocument = Boolean(documentData?.connectorId) const effectiveDocumentName = documentData?.filename || documentName || 'Document' /** @@ -398,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