From d51a7cc05586948f62b7efbbbf3c0bd041132fc0 Mon Sep 17 00:00:00 2001 From: andresdjasso Date: Tue, 18 Aug 2026 16:09:57 -0500 Subject: [PATCH 01/17] feat(resources): empty-state graphics for knowledge, tables, logs, files, skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four of the resource pages (knowledge, tables, logs, files) had no empty state at all — `Resource.Table` painted column headers over a blank scroll area and stopped there. Skills had a `: null` branch for zero data. Adds a graphic per resource, drawn in the editor vignette's recipe: take the product's own primitives, shrink them, strip the content to skeletons, and let the composition bleed off the frame edges. - Knowledge — a document fanning into the chunks it is embedded as, using the editor's 6px smooth-step connector language in --workflow-edge - Tables — a sheet of cells running off two edges with one cell in an edit ring - Logs — runs stacked newest-first, their trace spans staggered into a waterfall - Files — a folder held open with one file still above its dashed landing slot - Skills — a skill card opened far enough to show the tools bundled inside `Resource.Table` gains a sanctioned `emptyState` slot rendered below the column headers when `rows` is empty, so the chrome guarantee still holds. Each page shows the graphic only for true zero-data — never for a search or filter that matched nothing, never inside an empty subfolder, and (logs) never before the first page of runs lands. Also ports the shared `EmptyState` frame from the editor branch so this branch stands alone, and adds a review-only /empty-states-preview gallery route. Co-Authored-By: Claude Opus 5 --- apps/sim/app/empty-states-preview/page.tsx | 6 ++ .../files-empty-state.tsx | 55 +++++++++++ .../resource-empty-state/gallery-preview.tsx | 39 ++++++++ .../components/resource-empty-state/index.ts | 9 ++ .../knowledge-empty-state.tsx | 93 +++++++++++++++++++ .../resource-empty-state/logs-empty-state.tsx | 68 ++++++++++++++ .../skills-empty-state.tsx | 59 ++++++++++++ .../tables-empty-state.tsx | 71 ++++++++++++++ .../resource-empty-state/vignette.tsx | 58 ++++++++++++ .../components/resource/resource.tsx | 16 +++- .../workspace/[workspaceId]/files/files.tsx | 13 +++ .../[workspaceId]/knowledge/knowledge.tsx | 13 +++ .../app/workspace/[workspaceId]/logs/logs.tsx | 11 +++ .../workspace/[workspaceId]/skills/skills.tsx | 5 +- .../workspace/[workspaceId]/tables/tables.tsx | 13 +++ .../components/empty-state/empty-state.tsx | 31 +++++++ 16 files changed, 558 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/empty-states-preview/page.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/gallery-preview.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/skills-empty-state.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/vignette.tsx create mode 100644 apps/sim/components/empty-state/empty-state.tsx diff --git a/apps/sim/app/empty-states-preview/page.tsx b/apps/sim/app/empty-states-preview/page.tsx new file mode 100644 index 00000000000..e894bf035c6 --- /dev/null +++ b/apps/sim/app/empty-states-preview/page.tsx @@ -0,0 +1,6 @@ +import { ResourceEmptyStateGallery } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/gallery-preview' + +/** Review-only route for the resource empty-state graphics. Not linked in nav. */ +export default function EmptyStatesPreviewPage() { + return +} 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 new file mode 100644 index 00000000000..bb1e175743b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx @@ -0,0 +1,55 @@ +import { EmptyState } from '@/components/empty-state/empty-state' +import { + Bar, + Vignette, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/vignette' + +/** Name and size skeleton widths for the files already sitting in the folder. */ +const FOLDER_FILES = [ + { name: 84, size: 22 }, + { name: 62, size: 18 }, +] as const + +/** + * A folder held open with one file still in the air above its dashed landing + * slot — Files is the one resource whose empty state is really an invitation to + * drop something, so the graphic draws the gesture rather than the result. + */ +function FilesGraphic() { + return ( + + + +
+ {FOLDER_FILES.map((file) => ( +
+ + + +
+ ))} + + +
+ +
+ + + + + +
+
+ ) +} + +/** Empty state for the files list when the workspace has none. */ +export function FilesEmptyState() { + return ( + } + title='No files yet' + description='Drop files here to share them across your team and every agent.' + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/gallery-preview.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/gallery-preview.tsx new file mode 100644 index 00000000000..de934a01516 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/gallery-preview.tsx @@ -0,0 +1,39 @@ +'use client' + +import { + FilesEmptyState, + KnowledgeEmptyState, + LogsEmptyState, + SkillsEmptyState, + TablesEmptyState, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' + +const ENTRIES = [ + { label: 'Knowledge', node: }, + { label: 'Tables', node: }, + { label: 'Logs', node: }, + { label: 'Files', node: }, + { label: 'Skills', node: }, +] + +/** + * Review-only gallery: every resource empty state on one page so the family can + * be judged side by side. Not linked from the product. + */ +export function ResourceEmptyStateGallery() { + return ( +
+ {ENTRIES.map((entry) => ( +
+

+ {entry.label} +

+
{entry.node}
+
+ ))} +
+ ) +} 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 new file mode 100644 index 00000000000..83564a28735 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts @@ -0,0 +1,9 @@ +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' +export { SkillsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/skills-empty-state' +export { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state' +export { + Bar, + Vignette, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/vignette' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx new file mode 100644 index 00000000000..fc4b42cf973 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx @@ -0,0 +1,93 @@ +import { EmptyState } from '@/components/empty-state/empty-state' +import { + Bar, + Vignette, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/vignette' + +/** + * A document fanning into the chunks it is embedded as — the moment that makes a + * knowledge base a knowledge base, drawn in the editor vignette's connector + * language (6px-radius smooth steps in `--workflow-edge`). + */ +const CHUNK_EDGE_PATHS = [ + 'M190,80 H207 Q213,80 213,74 V52 Q213,46 219,46 H236', + 'M190,80 H236', + 'M190,80 H207 Q213,80 213,86 V108 Q213,114 219,114 H236', +] as const + +function KnowledgeGraphic() { + return ( + + + + + + + + + {CHUNK_EDGE_PATHS.map((d) => ( + + ))} + + + + + +
+
+ + +
+
+ + + + +
+
+ + {[35, 69, 103].map((top, index) => ( + + + + ))} +
+ ) +} + +/** Empty state for the knowledge bases list when the workspace has none. */ +export function KnowledgeEmptyState() { + return ( + } + title='No knowledge bases yet' + description='Upload documents to give your agents a memory they can search.' + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx new file mode 100644 index 00000000000..f254b67644b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx @@ -0,0 +1,68 @@ +import { EmptyState } from '@/components/empty-state/empty-state' +import { + Bar, + Vignette, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/vignette' + +interface LogRow { + status: 'success' | 'error' + /** Width of the workflow-name skeleton. */ + name: number + /** Trace span offset and width — together these read as a waterfall. */ + spanStart: number + spanWidth: number + duration: number +} + +const LOG_ROWS: LogRow[] = [ + { status: 'success', name: 68, spanStart: 0, spanWidth: 118, duration: 22 }, + { status: 'success', name: 52, spanStart: 12, spanWidth: 82, duration: 18 }, + { status: 'error', name: 74, spanStart: 24, spanWidth: 50, duration: 26 }, + { status: 'success', name: 58, spanStart: 14, spanWidth: 72, duration: 16 }, + { status: 'success', name: 64, spanStart: 4, spanWidth: 102, duration: 20 }, +] + +const SPAN_ORIGIN_X = 150 + +/** + * Runs stacked newest-first, each one a status dot, a workflow name, and its + * trace span — the spans stagger into the waterfall you get when you open a run. + */ +function LogsGraphic() { + return ( + + {LOG_ROWS.map((row, index) => ( +
+ + + + +
+ ))} +
+ ) +} + +/** Empty state for the logs list when the workspace has no runs yet. */ +export function LogsEmptyState() { + return ( + } + title='No runs yet' + description='Every workflow execution lands here, traced block by block.' + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/skills-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/skills-empty-state.tsx new file mode 100644 index 00000000000..198e085d494 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/skills-empty-state.tsx @@ -0,0 +1,59 @@ +import { EmptyState } from '@/components/empty-state/empty-state' +import { + Bar, + Vignette, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/vignette' + +/** Tool-pill widths inside the skill card — a skill is a bundle of tools. */ +const TOOL_PILLS = [38, 30, 44] as const + +/** + * A skill card over its stack, opened far enough to show the tools bundled + * inside it — the card is the unit you install, the pills are what it brings. + */ +function SkillsGraphic() { + return ( + + + + +
+
+ + + + + +
+ +
+ + +
+ +
+ {TOOL_PILLS.map((width) => ( + + + + ))} +
+
+
+ ) +} + +/** Empty state for the skills list when the workspace has none installed. */ +export function SkillsEmptyState() { + return ( + } + title='No skills yet' + description='Add a skill to give your agents a packaged set of tools to work with.' + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx new file mode 100644 index 00000000000..9cb5e2b0e1b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx @@ -0,0 +1,71 @@ +import { EmptyState } from '@/components/empty-state/empty-state' +import { + Bar, + Vignette, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/vignette' + +/** Cell fill widths per column, row by row — varied so the grid reads as data, not a lattice. */ +const CELL_WIDTHS = [ + [58, 40, 30, 44], + [48, 34, 36, 38], + [64, 44, 26, 46], + [42, 30, 34, 36], +] as const + +const COLUMN_TEMPLATE = '92px 76px 76px 76px' + +/** + * A sheet of cells running off the right and bottom edges, with one cell held in + * an edit ring — a table is the only resource whose structure *is* its picture. + */ +function TablesGraphic() { + return ( + +
+ {[0, 1, 2, 3].map((column) => ( +
+ + +
+ ))} + + {CELL_WIDTHS.map((row, rowIndex) => + row.map((width, column) => { + const isEditing = rowIndex === 1 && column === 1 + return ( +
+ + {isEditing ? ( + + ) : null} +
+ ) + }) + )} +
+
+ ) +} + +/** Empty state for the tables list when the workspace has none. */ +export function TablesEmptyState() { + return ( + } + title='No tables yet' + description='Create a table to store structured data your agents can read and write.' + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/vignette.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/vignette.tsx new file mode 100644 index 00000000000..6671d137ff5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/vignette.tsx @@ -0,0 +1,58 @@ +import type { CSSProperties, ReactNode } from 'react' +import { cn } from '@sim/emcn' + +/** Canonical vignette box. Every resource graphic is drawn on this 320x148 stage. */ +export const VIGNETTE_WIDTH = 320 +export const VIGNETTE_HEIGHT = 148 + +/** + * Edge falloff shared by every resource empty-state graphic. + * + * The editor vignette fades its connection strokes to zero opacity at the frame + * edge so the workflow reads as continuing off-canvas. These graphics carry + * skeleton geometry rather than strokes, so the same falloff is applied as a + * mask over the whole stage — the miniature is a window onto something larger, + * not a sticker with a hard border. + */ +const VIGNETTE_MASK = + '[mask-image:linear-gradient(to_right,transparent_0%,black_16%,black_84%,transparent_100%),linear-gradient(to_bottom,transparent_0%,black_18%,black_78%,transparent_100%)] [mask-composite:intersect] [-webkit-mask-composite:source-in]' + +interface VignetteProps { + children: ReactNode + className?: string +} + +/** + * Decorative stage for a resource empty-state graphic. + * + * Fixed size so every resource page's empty state occupies identical space, and + * `aria-hidden` throughout — the adjacent title and description carry all of the + * meaning. + */ +export function Vignette({ children, className }: VignetteProps) { + return ( + + ) +} + +interface BarProps { + className?: string + /** Escape hatch for computed geometry (widths driven by data arrays). */ + style?: CSSProperties +} + +/** Skeleton bar — the neutral stand-in for content inside a miniature. */ +export function Bar({ className, style }: BarProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 31ca47ae1bc..04b52c0b653 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -225,6 +225,13 @@ interface ResourceTableProps { * chrome and positioning; it never alters the table's rendering. */ overlay?: ReactNode + /** + * Sanctioned empty slot. Rendered below the column headers when `rows` is + * empty, filling the otherwise blank scroll area. It never replaces the table + * region or the headers — the chrome guarantee holds — so a consumer can show + * a zero-data graphic without the list losing its structure. + */ + emptyState?: ReactNode } /** @@ -263,10 +270,13 @@ const ResourceTable = memo(function ResourceTable({ isLoadingMore, pagination, overlay, + emptyState, }: ResourceTableProps) { const scrollRef = useRef(null) const loadMoreRef = useRef(null) + const showEmptyState = rows.length === 0 && Boolean(emptyState) + const [contextMenuRowId, setContextMenuRowId] = useState(null) const wrappedOnRowContextMenu = useCallback( @@ -358,7 +368,10 @@ const ResourceTable = memo(function ResourceTable({
+ {showEmptyState ? emptyState : null} {hasMore && (
{isLoadingMore && ( diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index f2fce25c67a..c2aa3ad09a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -85,6 +85,7 @@ import { useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' +import { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' @@ -1804,6 +1805,17 @@ export function Files() { return tags }, [typeFilter, sizeFilter, uploadedByFilter, membersById]) + /** + * The zero-data graphic is for a workspace with nothing in it — not for a + * search or filter that happened to match nothing, and not for an empty + * subfolder, both of which would make its copy wrong. + */ + const showEmptyState = + rows.length === 0 && + currentFolderId === null && + !urlSearchTerm.trim() && + filterTags.length === 0 + if (fileIdFromRoute && !selectedFile && isLoading) { return ( @@ -1904,6 +1916,7 @@ export function Files() { filter={filterConfig} /> : undefined} columns={COLUMNS} rows={rows} selectable={selectableConfig} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 207f8b9599e..da105121fb5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -54,6 +54,7 @@ import { useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' +import { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { CreateBaseModal, @@ -1338,6 +1339,17 @@ export function Knowledge() { return tags }, [connectorFilter, contentFilter, ownerFilter, members]) + /** + * The zero-data graphic is for a workspace with nothing in it — not for a + * search or filter that happened to match nothing, and not for an empty + * subfolder, both of which would make its copy wrong. + */ + const showEmptyState = + rows.length === 0 && + currentFolderId === null && + !urlSearchQuery.trim() && + filterTags.length === 0 + return ( <> @@ -1355,6 +1367,7 @@ export function Knowledge() { filter={filterConfig} /> : undefined} columns={COLUMNS} rows={rows} selectable={canEdit ? selectableConfig : undefined} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index 17cc80be70c..0a3b94ec6f4 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -58,6 +58,7 @@ import type { SortConfig, } from '@/app/workspace/[workspaceId]/components' import { Resource, type ResourceTableHandle } from '@/app/workspace/[workspaceId]/components' +import { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters' import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state' import { @@ -900,6 +901,15 @@ export default function Logs() { setTimeRange, ]) + /** + * The zero-data graphic means "this workspace has never run anything" — a + * search or filter that matched nothing is a different message, so neither + * gets the graphic. Held back while the first page is in flight so it cannot + * flash before the runs arrive. + */ + const showEmptyState = + rows.length === 0 && !logsQuery.isLoading && !debouncedSearchQuery && filterTags.length === 0 + const workflowsData = useMemo( () => Object.values(allWorkflows).map((w) => ({ @@ -1191,6 +1201,7 @@ export default function Logs() { ) : ( : undefined} virtualized columns={LOG_COLUMNS} rows={rows} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx index 2e89a134965..8562071381b 100644 --- a/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx +++ b/apps/sim/app/workspace/[workspaceId]/skills/skills.tsx @@ -7,6 +7,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' import { IntegrationTabsHeader, SkillTile } from '@/app/workspace/[workspaceId]/components' +import { SkillsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore' import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { @@ -125,7 +126,9 @@ export function Skills() { No skills found matching “{searchTerm}” - ) : null} + ) : isLoading ? null : ( + + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index dc5e89a8742..26fc0da476f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -53,6 +53,7 @@ import { useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' +import { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { @@ -731,6 +732,17 @@ export function Tables() { return tags }, [rowCountFilter, ownerFilter, membersById, setRowCountFilter, setOwnerFilter]) + /** + * The zero-data graphic is for a workspace with nothing in it — not for a + * search or filter that happened to match nothing, and not for an empty + * subfolder, both of which would make its copy wrong. + */ + const showEmptyState = + rows.length === 0 && + currentFolderId === null && + !urlSearchTerm.trim() && + filterTags.length === 0 + const handleContentContextMenu = useCallback( (e: React.MouseEvent) => { const target = e.target as HTMLElement @@ -1267,6 +1279,7 @@ export function Tables() { filter={filterConfig} /> : undefined} columns={COLUMNS} rows={rows} selectable={canEdit ? selectableConfig : undefined} diff --git a/apps/sim/components/empty-state/empty-state.tsx b/apps/sim/components/empty-state/empty-state.tsx new file mode 100644 index 00000000000..9711afd83e6 --- /dev/null +++ b/apps/sim/components/empty-state/empty-state.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' + +interface EmptyStateProps { + title: string + description: string + graphic?: ReactNode + action?: ReactNode + className?: string +} + +/** + * Shared platform empty-state frame with a visual, concise guidance, and an optional action. + */ +export function EmptyState({ title, description, graphic, action, className }: EmptyStateProps) { + return ( +
+ {graphic ?
{graphic}
: null} +

{title}

+

+ {description} +

+ {action ?
{action}
: null} +
+ ) +} From 4e0f3de2bac04c3c950a44ec77ed3c5d0d006780 Mon Sep 17 00:00:00 2001 From: andresdjasso Date: Tue, 18 Aug 2026 16:21:25 -0500 Subject: [PATCH 02/17] improvement(tables): redraw the empty-state graphic in the house grayscale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the workflow editor's vignette and the landing feature graphics, which between them use no brand colour at all — every one of them is built from neutral tokens. Two corrections: - The blue edit ring is gone. Nothing in the reference graphics carries a hue, and it was the loudest element on the page. - `--surface-4`/`--surface-5` are near-white in light mode (#f5f5f5/#f3f3f3), so skeleton geometry built on them dissolved on a white card. Bars now mix `--text-secondary` into transparent at graded strengths — a real mid-grey that inverts with the theme, which is the idiom the editor vignette already uses for the one bar it needs you to see. Also drops the full-composition mask. The editor vignette keeps its block fully opaque and fades only the connector strokes leaving the frame; masking everything is what made the miniature read washed rather than deliberate. The card is crisp now and the continuation is drawn the way a real table draws it — an overflow fade at the edge the columns run off. Co-Authored-By: Claude Opus 5 --- .../tables-empty-state.tsx | 122 ++++++++++++------ 1 file changed, 81 insertions(+), 41 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx index 9cb5e2b0e1b..b0574859f25 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx @@ -1,61 +1,101 @@ +import { cn } from '@sim/emcn' import { EmptyState } from '@/components/empty-state/empty-state' -import { - Bar, - Vignette, -} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/vignette' -/** Cell fill widths per column, row by row — varied so the grid reads as data, not a lattice. */ +/** + * Neutral ink at graded strengths. + * + * `--surface-4`/`--surface-5` are near-white in light mode (`#f5f5f5`/`#f3f3f3`), + * so skeleton geometry built on them dissolves on a white card. Mixing + * `--text-secondary` into transparent instead gives a real mid-grey that inverts + * with the theme — the same idiom the workflow editor's empty state uses for the + * one bar it needs you to actually see. + */ +const INK = { + header: 'color-mix(in srgb, var(--text-secondary) 32%, transparent)', + headerType: 'color-mix(in srgb, var(--text-secondary) 26%, transparent)', + cell: 'color-mix(in srgb, var(--text-secondary) 17%, transparent)', + selection: 'color-mix(in srgb, var(--text-secondary) 30%, transparent)', +} as const + +const COLUMN_TEMPLATE = '84px 66px 66px 66px' + +/** Header label widths, then per-row cell widths — varied so the grid reads as data. */ +const HEADER_WIDTHS = [34, 26, 24, 26] as const + const CELL_WIDTHS = [ - [58, 40, 30, 44], - [48, 34, 36, 38], - [64, 44, 26, 46], - [42, 30, 34, 36], + [52, 34, 26, 38], + [44, 30, 32, 30], + [58, 38, 22, 40], + [38, 26, 30, 32], ] as const -const COLUMN_TEMPLATE = '92px 76px 76px 76px' - /** - * A sheet of cells running off the right and bottom edges, with one cell held in - * an edit ring — a table is the only resource whose structure *is* its picture. + * A crisp table card whose columns run past its right edge. + * + * The workflow editor's vignette keeps its block fully opaque and fades only the + * connector strokes leaving the frame; masking the whole composition is what + * makes a miniature look washed rather than deliberate. So the card here stays + * sharp and the continuation is drawn the way a real table draws it — an + * overflow fade at the edge the columns run off. */ function TablesGraphic() { return ( - -
- {[0, 1, 2, 3].map((column) => ( + - +
) } From 7f1e4aba06c32cc88e5f2490bee9198b0c883c11 Mon Sep 17 00:00:00 2001 From: andresdjasso Date: Tue, 18 Aug 2026 16:25:24 -0500 Subject: [PATCH 03/17] improvement(tables): strip the empty-state graphic to ruled lines and a corner fade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minimal pass. The card is gone — no border, no fill, no header shading, no type squares. What is left is the grid itself: hairline rules in `--border-1`, ink bars at two strengths, and the one cell held in an edit ring. With no card fill the grid sits directly on the page, so it can dissolve into the background instead of ending at a border. The fade is the landing page's own idiom — two gradients intersected (`mask-composite: intersect`), crisp at the top-left and gone through the bottom-right, the same construction `workflow-graph-preview` uses. Two placement notes: - The grid is offset right of frame centre. A diagonal dissolve puts the visual mass toward its opaque corner, so centring the geometry would leave the graphic reading left of the copy beneath it. - The selected cell sits in the quadrant the fade leaves fully opaque. A selection ring dissolving mid-stroke reads as a rendering fault, not a detail. Co-Authored-By: Claude Opus 5 --- .../tables-empty-state.tsx | 86 +++++++++---------- 1 file changed, 39 insertions(+), 47 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx index b0574859f25..34939bcd15a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx @@ -1,62 +1,60 @@ -import { cn } from '@sim/emcn' import { EmptyState } from '@/components/empty-state/empty-state' /** - * Neutral ink at graded strengths. + * Neutral ink at two strengths. * - * `--surface-4`/`--surface-5` are near-white in light mode (`#f5f5f5`/`#f3f3f3`), - * so skeleton geometry built on them dissolves on a white card. Mixing - * `--text-secondary` into transparent instead gives a real mid-grey that inverts - * with the theme — the same idiom the workflow editor's empty state uses for the - * one bar it needs you to actually see. + * `--surface-4`/`--surface-5` are near-white in light mode (#f5f5f5/#f3f3f3), so + * skeleton geometry built on them dissolves against the page. Mixing + * `--text-secondary` into transparent gives a real mid-grey that inverts with the + * theme — the idiom the workflow editor's vignette uses for the one bar it needs + * you to actually see. */ const INK = { - header: 'color-mix(in srgb, var(--text-secondary) 32%, transparent)', - headerType: 'color-mix(in srgb, var(--text-secondary) 26%, transparent)', - cell: 'color-mix(in srgb, var(--text-secondary) 17%, transparent)', - selection: 'color-mix(in srgb, var(--text-secondary) 30%, transparent)', + header: 'color-mix(in srgb, var(--text-secondary) 30%, transparent)', + cell: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)', + selection: 'color-mix(in srgb, var(--text-secondary) 32%, transparent)', } as const -const COLUMN_TEMPLATE = '84px 66px 66px 66px' +/** + * Crisp at the top-left, dissolving through the bottom-right — the same + * two-gradient intersect the landing page's workflow vignette uses, so the grid + * blends into the page rather than sitting on it as a card. + */ +const CORNER_FADE = + '[-webkit-mask-image:linear-gradient(to_right,#000_62%,transparent_100%),linear-gradient(to_bottom,#000_56%,transparent_100%)] [mask-image:linear-gradient(to_right,#000_62%,transparent_100%),linear-gradient(to_bottom,#000_56%,transparent_100%)] [-webkit-mask-composite:source-in] [mask-composite:intersect]' -/** Header label widths, then per-row cell widths — varied so the grid reads as data. */ -const HEADER_WIDTHS = [34, 26, 24, 26] as const +const COLUMN_TEMPLATE = '88px 72px 72px 72px' + +const HEADER_WIDTHS = [36, 28, 26, 28] as const const CELL_WIDTHS = [ - [52, 34, 26, 38], - [44, 30, 32, 30], - [58, 38, 22, 40], - [38, 26, 30, 32], + [54, 34, 26, 38], + [44, 30, 34, 30], + [60, 38, 22, 40], + [40, 26, 30, 34], + [50, 32, 28, 36], ] as const /** - * A crisp table card whose columns run past its right edge. - * - * The workflow editor's vignette keeps its block fully opaque and fades only the - * connector strokes leaving the frame; masking the whole composition is what - * makes a miniature look washed rather than deliberate. So the card here stays - * sharp and the continuation is drawn the way a real table draws it — an - * overflow fade at the edge the columns run off. + * The one cell held in an edit ring, placed in the top-left quadrant the corner + * fade leaves fully opaque — a selection dissolving mid-stroke would read as a + * rendering fault rather than a detail. */ +const SELECTED_CELL = { row: 1, column: 0 } as const + +/** Nothing but ruled lines and the ink sitting in them, running off two edges. */ function TablesGraphic() { return ( -