From 62c016b92499a3a706463a78660ef94d24e5d9eb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 19 Aug 2026 12:30:12 -0700 Subject: [PATCH] feat(knowledge): empty state for a base that holds no documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6828 gave the four workspace resource lists a zero-data graphic and left the one list a level below them still painting column headers over a blank body — the list every user meets immediately, because creating a base does not require a file and does not navigate anywhere on success. The mark is a stack of sheets with the front one dog-eared and ruled. The dog-ear is the one signifier the set does not already use: the folder is a container and the knowledge mark is a shelf of volumes, and this has to read as the pages inside one of them rather than as either. It reuses the rest of the recipe — hairline contours, the surface ramp for depth, ink mixed off `--text-secondary` because the ramp is near-white in light mode, and a fade running the direction the stack recedes. `HAIRLINE` was byte-identical between the folder and this mark, so it moves to a shared module beside `mask.ts`. Visibility goes through the same `isResourceListEmpty` the four pages use, with one difference the call site documents: it counts the server's `total` rather than the visible rows, because this list is paginated and an empty page 2 is a paging position, not an empty base. The folder arguments are omitted — a base's documents are flat. The frame is derived from the artwork's bounds rather than a round-numbered viewBox. The sheets step up and to the right, which left the drawn mass far enough off-centre that the mark sat visibly right of the copy beneath it. --- .../documents-empty-state.tsx | 140 ++++++++++++++++++ .../files-empty-state.tsx | 16 +- .../resource-empty-state/hairline.ts | 15 ++ .../components/resource-empty-state/index.ts | 1 + .../[workspaceId]/knowledge/[id]/base.tsx | 30 ++++ 5 files changed, 187 insertions(+), 15 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/documents-empty-state.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline.ts diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/documents-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/documents-empty-state.tsx new file mode 100644 index 00000000000..d94d5337eac --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/documents-empty-state.tsx @@ -0,0 +1,140 @@ +import { Chip, cn } from '@sim/emcn' +import { Plus } from '@sim/emcn/icons' +import { EmptyState } from '@/components/empty-state/empty-state' +import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' +import { HAIRLINE } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline' +import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' + +const KNOWLEDGE_DOCS_URL = 'https://docs.sim.ai/knowledgebase' + +/** + * Ruled text — see the `INK` note in `tables-empty-state.tsx` for why not the surface + * ramp. The heading line takes the stronger mix so the sheet reads as a document + * rather than as blank ruling. + */ +const INK = { + heading: 'color-mix(in srgb, var(--text-secondary) 30%, transparent)', + body: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)', +} as const + +/** Widths of the ruled body lines, in viewBox units. */ +const BODY_LINES = [ + { y: 84, width: 62 }, + { y: 98, width: 54 }, + { y: 112, width: 66 }, +] as const + +/** + * The front sheet, with the top-right corner turned back. + * + * The dog-ear is the one signifier no other graphic in the set uses — the folder is a + * container, the knowledge mark is a shelf of volumes, and this has to read as the + * pages inside one of them. + */ +const SHEET = [ + 'M 58 34', + 'H 128', + 'L 148 54', + 'V 140', + 'Q 148 146 142 146', + 'H 58', + 'Q 52 146 52 140', + 'V 40', + 'Q 52 34 58 34', + 'Z', +].join(' ') + +/** The turned-back corner itself, drawn over the sheet so its two edges both read. */ +const DOG_EAR = ['M 128 34', 'L 148 54', 'H 134', 'Q 128 54 128 48', 'Z'].join(' ') + +/** + * Dissolves upward, the direction the stack recedes — the same call the folder makes. + * The sheets behind are a repeating structure and cost nothing cropped, while the front + * sheet stays crisp where the copy begins beneath it. + */ +const STACK_FADE = + '[-webkit-mask-image:linear-gradient(to_top,#000_56%,transparent_100%)] [mask-image:linear-gradient(to_top,#000_56%,transparent_100%)]' + +/** + * The artwork's own bounds, so the frame follows the drawing rather than a round number. + * + * The sheets are authored around the front sheet's origin and step up and to the right, + * which leaves the drawn mass off-centre inside any round-numbered viewBox — enough that + * the mark sat visibly right of the copy beneath it. Framing on these bounds centres the + * two. They are read off the shapes below by hand, so retuning the stack means updating + * them here. + */ +const ART = { minX: 52, maxX: 166, minY: 18, maxY: 146 } as const +const ART_PADDING = 10 +const VIEW_BOX_WIDTH = ART.maxX - ART.minX + ART_PADDING * 2 +const VIEW_BOX_HEIGHT = ART.maxY - ART.minY + ART_PADDING * 2 +const VIEW_BOX = `${ART.minX - ART_PADDING} ${ART.minY - ART_PADDING} ${VIEW_BOX_WIDTH} ${VIEW_BOX_HEIGHT}` + +/** Matches the other resource graphics, so the set centres as one collection. */ +const MARK_HEIGHT = 148 + +/** + * A stack of sheets, the front one dog-eared and ruled. The two behind it step up and + * to the right, so the stack reads as depth rather than as a single thick page. + */ +function DocumentsGraphic() { + return ( + + ) +} + +interface DocumentsEmptyStateProps { + /** Opens the file picker — the same action the header's primary chip runs. */ + onAddDocuments: () => void + /** Mirrors the header chip's disabled state: no edit rights on the workspace. */ + addDisabled?: boolean +} + +/** Empty state for a knowledge base that holds no documents yet. */ +export function DocumentsEmptyState({ + onAddDocuments, + addDisabled = false, +}: DocumentsEmptyStateProps) { + return ( + } + title='Documents' + description='Upload documents so your agents can search this base.' + action={ + <> + + New documents + + + + } + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx index ae1735a7015..4fb2d63be38 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx @@ -2,25 +2,11 @@ import { Chip, cn } from '@sim/emcn' import { Upload } from '@sim/emcn/icons' import { EmptyState } from '@/components/empty-state/empty-state' import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link' +import { HAIRLINE } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline' import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask' const FILES_DOCS_URL = 'https://docs.sim.ai/files' -/** - * Hairline contours, matching the tables grid's 1px `--border` rules and the - * knowledge mark's thinned strokes — the graphics sit one nav item apart, so a - * heavier outline here would read as a different illustration system. - * - * Fills stay near the top of the surface ramp for the same reason: the border - * draws the folder and the fill only has to separate one layer from the next. A - * solid mid-grey body made this the heaviest thing on the page. - */ -const HAIRLINE = { - stroke: 'var(--border)', - strokeWidth: 1.1, - strokeLinejoin: 'round' as const, -} as const - const FOLDER_BACK = [ 'M 22 34', 'H 66', diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline.ts new file mode 100644 index 00000000000..6047c31ba94 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline.ts @@ -0,0 +1,15 @@ +/** + * The contour weight every drawn resource graphic shares. + * + * Matches the tables grid's 1px `--border` rules and the knowledge mark's thinned + * strokes — the graphics sit a nav level apart, so a heavier outline on any one of + * them reads as a different illustration system. + * + * Fills stay near the top of the surface ramp for the same reason: the border draws + * the shape and the fill only has to separate one layer from the next. + */ +export const HAIRLINE = { + stroke: 'var(--border)', + strokeWidth: 1.1, + strokeLinejoin: 'round' as const, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts index 7b34c26e6b5..59e06a779dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts @@ -1,3 +1,4 @@ +export { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/documents-empty-state' export { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state' export { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state' export { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index a01bb376e57..7f963ea85c8 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -61,6 +61,7 @@ import type { import { FILTER_SECTION_LABEL_CLASS, FloatingOverflowText, + isResourceListEmpty, Resource, } from '@/app/workspace/[workspaceId]/components' import { @@ -69,6 +70,7 @@ import { folderedResourceListHref, useFolderAncestors, } from '@/app/workspace/[workspaceId]/components/folders' +import { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components' import { ActionBar, @@ -411,6 +413,7 @@ export function KnowledgeBase({ const { documents, pagination, + isLoading: isLoadingDocuments, isPlaceholderData: isPlaceholderDocuments, error: documentsError, hasProcessingDocuments, @@ -1145,6 +1148,25 @@ export function KnowledgeBase({ [enabledFilter, tagFilterEntries] ) + /** + * The zero-data graphic is for a base that holds no documents — not a search or a + * filter that matched nothing, and not a page past the last one. Counts on the + * server's `total` rather than the visible rows, because this list is paginated: + * an empty page 2 is a paging position, not an empty base. + * + * The remaining gates mirror the workspace resource pages — the list must have + * arrived, must not be serving the previous query key's rows, and must not have + * failed. This list has no folders, so the folder arguments are omitted. + */ + const showEmptyState = isResourceListEmpty({ + rowCount: pagination.total, + isLoading: isLoadingDocuments, + isPlaceholderData: isPlaceholderDocuments, + error: documentsError, + search: debouncedSearchQuery, + filterCount: filterTags.length, + }) + const selectableConfig: SelectableConfig = { selectedIds: selectedDocuments, onSelectRow: handleSelectDocument, @@ -1271,6 +1293,14 @@ export function KnowledgeBase({ + ) : undefined + } selectable={selectableConfig} onRowClick={handleDocumentClick} onRowContextMenu={handleDocumentContextMenu}