diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx
index 296f0a01318..00f921aaca9 100644
--- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx
+++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-build-illustration.tsx
@@ -5,7 +5,7 @@ import {
ISO_FILL_LOW,
ISO_FILL_MID,
ISO_STROKE,
-} from '@/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style'
+} from '@/components/iso/iso-illustration-style'
export interface IsoBuildIllustrationProps {
size?: number
diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx
index 9411b4b3fa9..980bb792339 100644
--- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx
+++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-ingest-illustration.tsx
@@ -9,7 +9,7 @@ import {
ISO_FILL_PULSE_LOW,
ISO_FILL_PULSE_MID,
ISO_STROKE,
-} from '@/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style'
+} from '@/components/iso/iso-illustration-style'
export interface IsoIngestIllustrationProps {
size?: number
diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx
index 955fca0f61e..4981feb35b0 100644
--- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx
+++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-integrate-illustration.tsx
@@ -5,7 +5,7 @@ import {
ISO_FILL_LOW,
ISO_FILL_MID,
ISO_STROKE,
-} from '@/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style'
+} from '@/components/iso/iso-illustration-style'
export interface IsoIntegrateIllustrationProps {
size?: number
diff --git a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx
index dd7643d14d3..b70bab7a7b1 100644
--- a/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx
+++ b/apps/sim/app/(landing)/components/mothership/components/iso-marks/iso-monitor-illustration.tsx
@@ -5,7 +5,7 @@ import {
ISO_FILL_LOW,
ISO_FILL_MID,
ISO_STROKE,
-} from '@/app/(landing)/components/mothership/components/iso-marks/iso-illustration-style'
+} from '@/components/iso/iso-illustration-style'
export interface IsoMonitorIllustrationProps {
size?: number
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link.tsx
new file mode 100644
index 00000000000..f0d47606e7f
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link.tsx
@@ -0,0 +1,21 @@
+import { ChipLink } from '@sim/emcn'
+import { BookOpen } from '@sim/emcn/icons'
+
+interface EmptyStateDocsLinkProps {
+ href: string
+}
+
+/** The docs chip every resource empty state carries, so the four stay identical. */
+export function EmptyStateDocsLink({ href }: EmptyStateDocsLinkProps) {
+ return (
+
+ Docs
+
+ )
+}
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..09a5f802c6c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state.tsx
@@ -0,0 +1,103 @@
+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'
+
+const FILES_DOCS_URL = 'https://docs.sim.ai/files'
+
+/**
+ * Hairline contours, matching the tables grid's 1px `--border-1` 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-1)',
+ strokeWidth: 1.1,
+ strokeLinejoin: 'round' as const,
+} as const
+
+const FOLDER_BACK = [
+ 'M 22 34',
+ 'H 66',
+ 'Q 72 34 75.5 38',
+ 'L 82.5 46',
+ 'Q 86 50 92 50',
+ 'H 178',
+ 'Q 188 50 188 60',
+ 'V 140',
+ 'Q 188 150 178 150',
+ 'H 22',
+ 'Q 12 150 12 140',
+ 'V 44',
+ 'Q 12 34 22 34',
+ 'Z',
+].join(' ')
+
+/**
+ * Dissolves upward, so the folder rises out of the page while its front panel
+ * stays crisp where the copy begins. Running it the other way — the direction the
+ * logs feed fades — ate the panel's base and left the tab hanging.
+ */
+const FOLDER_FADE =
+ '[-webkit-mask-image:linear-gradient(to_top,#000_58%,transparent_100%)] [mask-image:linear-gradient(to_top,#000_58%,transparent_100%)]'
+
+/**
+ * A folder held open with sheets standing proud of its front panel.
+ *
+ * Depth is carried by the surface ramp rather than by shadow: the back panel is the
+ * darkest tier, the sheets the lightest, the front panel between them. Shadows
+ * would need separate light and dark recipes; the ramp inverts on its own.
+ *
+ * Every outer corner shares the same 10-unit radius so the silhouette reads as a
+ * single drawn shape — mixing radii makes the corners fight each other at this size.
+ */
+function FilesGraphic() {
+ return (
+
+ )
+}
+
+interface FilesEmptyStateProps {
+ /** Opens the file picker — the same action the header's upload chip runs. */
+ onUpload: () => void
+ /** Mirrors the header chip's disabled state: no edit rights, or an upload in flight. */
+ uploadDisabled?: boolean
+}
+
+/** Empty state for the files list when the workspace has none. */
+export function FilesEmptyState({ onUpload, uploadDisabled = false }: FilesEmptyStateProps) {
+ return (
+ }
+ title='Files'
+ description='Upload files to share them across your team and every agent.'
+ action={
+ <>
+
+ Upload
+
+
+ >
+ }
+ />
+ )
+}
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..7b34c26e6b5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/index.ts
@@ -0,0 +1,4 @@
+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 { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state'
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..dcfdc7a3664
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state.tsx
@@ -0,0 +1,36 @@
+import { Chip } 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 { KnowledgeIsoMark } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso'
+
+const KNOWLEDGE_DOCS_URL = 'https://docs.sim.ai/knowledgebase'
+
+interface KnowledgeEmptyStateProps {
+ /** Opens the create-base modal — the same action the header's primary chip runs. */
+ onCreate: () => void
+ /** Mirrors the header chip's disabled state: no edit rights on the workspace. */
+ createDisabled?: boolean
+}
+
+/** Empty state for the knowledge bases list when the workspace has none. */
+export function KnowledgeEmptyState({
+ onCreate,
+ createDisabled = false,
+}: KnowledgeEmptyStateProps) {
+ return (
+ }
+ title='Knowledge bases'
+ description='Upload documents to give your agents a memory they can search.'
+ action={
+ <>
+
+ New base
+
+
+ >
+ }
+ />
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso.tsx
new file mode 100644
index 00000000000..37cd2b50a9f
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-iso.tsx
@@ -0,0 +1,231 @@
+import { cn } from '@sim/emcn'
+import {
+ createIsoLineProps,
+ ISO_FILL_HIGH,
+ ISO_FILL_LOW,
+ ISO_FILL_MID,
+ ISO_FILL_PULSE_LOW,
+ ISO_STROKE as ISO_STROKE_BASE,
+} from '@/components/iso/iso-illustration-style'
+
+const COS_30 = Math.cos(Math.PI / 6)
+
+type Point = readonly [number, number]
+
+/** Standard isometric projection: +x right-and-down, +y left-and-down, +z up. */
+function project(x: number, y: number, z: number): Point {
+ return [(x - y) * COS_30, (x + y) * 0.5 - z]
+}
+
+function toPath(points: Point[]): string {
+ return `${points.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`).join(' ')} Z`
+}
+
+interface Box {
+ x: number
+ y: number
+ z: number
+ w: number
+ d: number
+ h: number
+}
+
+/** The three faces an isometric viewer can see, brightest on top. */
+function boxFaces(box: Box) {
+ const { x, y, z, w, d, h } = box
+ const x1 = x + w
+ const y1 = y + d
+ const z1 = z + h
+ return {
+ top: [project(x, y, z1), project(x1, y, z1), project(x1, y1, z1), project(x, y1, z1)],
+ right: [project(x1, y, z1), project(x1, y1, z1), project(x1, y1, z), project(x1, y, z)],
+ left: [project(x, y1, z1), project(x1, y1, z1), project(x1, y1, z), project(x, y1, z)],
+ }
+}
+
+/**
+ * Thin along y rather than x, so the cover is the face at max y — the one the
+ * projection turns to the left. Stacking along y then walks the set left-and-down
+ * toward the viewer, so drawing in offset order paints back to front.
+ */
+const SLABS: Box[] = [0, 90, 180].map((offset) => ({
+ x: 0,
+ y: offset,
+ z: 0,
+ w: 208,
+ d: 46,
+ h: 236,
+}))
+
+/**
+ * Lighter and thinner than the landing marks draw them.
+ *
+ * Those marks are the focal art of their section; here the mark sits beside a
+ * ruled grid and a skeleton feed whose lines are 1px of `--border-1`. Carrying
+ * the landing's full-weight contour made the volumes read as ink next to those,
+ * so the shared stroke is mixed toward `--border-1` and thinned to land near a
+ * hairline once the mark is scaled to empty-state size. Only the width diverges
+ * from the shared recipe; the fills are imported so a change to the iso ramp
+ * reaches this mark too.
+ */
+const ISO_STROKE = `color-mix(in srgb, ${ISO_STROKE_BASE} 55%, var(--border-1))`
+/** Darker than any outer face — the bore's wall turns away from the light. */
+const ISO_FILL_BORE = ISO_FILL_PULSE_LOW
+const KNOWLEDGE_STROKE_WIDTH = 1.9
+
+/** Matches the other three resource graphics, so the set centres as one collection. */
+const MARK_HEIGHT = 148
+
+/**
+ * Maps flat artwork into the plane of the front volume's cover.
+ *
+ * The cover is the face at max y, spanned by the volume's width going across and
+ * its height going up. Those two edges are its basis vectors in projected space,
+ * and a matrix built from them lets a plain `` be authored in face
+ * coordinates — the projection skews it into the right ellipse. Local units are
+ * world units measured on the face, so a circle stays circular *on the cover*
+ * instead of being stretched by the face's aspect.
+ */
+const COVER = SLABS[SLABS.length - 1]
+
+const COVER_PLANE = (() => {
+ const [originX, originY] = project(COVER.x, COVER.y + COVER.d, COVER.z)
+ return `matrix(${COS_30.toFixed(4)} 0.5 0 1 ${originX.toFixed(3)} ${(originY - COVER.h).toFixed(3)})`
+})()
+
+const BORE_RADIUS = 62
+const BORE_CX = COVER.w / 2
+const BORE_CY = COVER.h / 2
+
+/**
+ * The far mouth of the bore, in the same cover-plane coordinates.
+ *
+ * Boring straight back through the volume is a world-space step of `-d` along y.
+ * Solving the cover-plane matrix for the local offset that produces that step
+ * gives `(+d, -d)` — so the far mouth sits up and left of the near one by exactly
+ * the volume's thickness, and the sliver of near-mouth it fails to cover is the
+ * wall you see down the hole.
+ */
+const FAR_CX = BORE_CX + COVER.d
+const FAR_CY = BORE_CY - COVER.d
+
+const BORE_MASK_ID = 'knowledge-iso-bore-mask'
+const BORE_CLIP_ID = 'knowledge-iso-bore-clip'
+
+const ALL_POINTS: Point[] = SLABS.flatMap((box) => Object.values(boxFaces(box)).flat())
+
+/** `SLABS` is a module constant and the projection is pure, so every face path is fixed. */
+const SLAB_PATHS = SLABS.map((box) => {
+ const faces = boxFaces(box)
+ return {
+ key: `${box.x}-${box.y}`,
+ left: toPath(faces.left),
+ right: toPath(faces.right),
+ top: toPath(faces.top),
+ }
+})
+
+const PADDING = 14
+const MIN_X = Math.min(...ALL_POINTS.map(([x]) => x)) - PADDING
+const MAX_X = Math.max(...ALL_POINTS.map(([x]) => x)) + PADDING
+const MIN_Y = Math.min(...ALL_POINTS.map(([, y]) => y)) - PADDING
+const MAX_Y = Math.max(...ALL_POINTS.map(([, y]) => y)) + PADDING
+const VIEW_BOX = `${MIN_X.toFixed(2)} ${MIN_Y.toFixed(2)} ${(MAX_X - MIN_X).toFixed(2)} ${(MAX_Y - MIN_Y).toFixed(2)}`
+
+/**
+ * The tables grid's corner fade, run along the other diagonal.
+ *
+ * There it dissolves toward the bottom-right, because a grid keeps its meaning
+ * cropped. Here the set recedes up and to the right, and the front volume carries
+ * the bore — so the fade is anchored at the bottom-left and eats into the back of
+ * the set instead, reading as more volumes behind rather than dissolving the one
+ * detail worth looking at.
+ */
+const STACK_FADE =
+ '[-webkit-mask-image:linear-gradient(to_right,#000_56%,transparent_100%),linear-gradient(to_bottom,transparent_0%,#000_40%)] [mask-image:linear-gradient(to_right,#000_56%,transparent_100%),linear-gradient(to_bottom,transparent_0%,#000_40%)] [-webkit-mask-composite:source-in] [mask-composite:intersect]'
+
+/** Shared iso contour recipe at this mark's own weight; spread onto both paths and circles. */
+const LINE_PROPS = createIsoLineProps(
+ undefined,
+ ISO_STROKE,
+ KNOWLEDGE_STROKE_WIDTH
+)
+
+/**
+ * Down the hole: the near mouth is floored with the wall tone, then the far mouth
+ * is painted over it in the cover tone of the volume standing behind — looking
+ * through a bore in the front volume lands on that volume's face, not on the page.
+ * Both are clipped to the near mouth so the bore never paints outside its opening.
+ */
+function BoreInterior() {
+ return (
+
+
+
+
+
+ )
+}
+
+/**
+ * Isometric knowledge-base mark on the landing page's iso-illustration recipe.
+ *
+ * Geometry is authored in a large unit space so the shared stroke constant lands
+ * as a hairline once the mark is scaled to empty-state size — as it does on the
+ * landing marks, which draw 3.2 into a ~526-unit viewBox.
+ */
+export function KnowledgeIsoMark() {
+ const width = MARK_HEIGHT * ((MAX_X - MIN_X) / (MAX_Y - MIN_Y))
+ return (
+
+ )
+}
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..6ea0d5ba336
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state.tsx
@@ -0,0 +1,76 @@
+import { cn } from '@sim/emcn'
+import { EmptyState } from '@/components/empty-state/empty-state'
+import { EmptyStateDocsLink } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/docs-link'
+
+const LOGS_DOCS_URL = 'https://docs.sim.ai/logs-debugging'
+
+/** Skeleton ink — see the `INK` note in `tables-empty-state.tsx` for why not the surface ramp. */
+const INK = {
+ title: 'color-mix(in srgb, var(--text-secondary) 32%, transparent)',
+ detail: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)',
+} as const
+
+interface ActivityRow {
+ stamp: string
+ title: number
+ detail: number
+}
+
+const ROWS: ActivityRow[] = [
+ { stamp: 'Now', title: 72, detail: 112 },
+ { stamp: '12 min ago', title: 62, detail: 124 },
+ { stamp: '1h ago', title: 76, detail: 100 },
+ { stamp: 'Jul 8', title: 56, detail: 108 },
+]
+
+/**
+ * Vertical falloff so the feed dissolves into the page instead of ending on a hard
+ * last row — the list is a repeating structure, so cropping it costs nothing.
+ */
+const FEED_FADE =
+ '[-webkit-mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)] [mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)]'
+
+/** Four rows, sized to the ~148px the other resource graphics occupy so the frame centres the set alike. */
+function LogsGraphic() {
+ return (
+
+ {ROWS.map((row, index) => (
+
+
+
+
+
+
+
+ {row.stamp}
+
+
+ ))}
+
+ )
+}
+
+/** Empty state for the logs list when the workspace has no runs yet. */
+export function LogsEmptyState() {
+ return (
+ }
+ title='Logs'
+ description='Every workflow execution lands here, traced block by block.'
+ action={}
+ />
+ )
+}
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..5ca521d2e99
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state.tsx
@@ -0,0 +1,128 @@
+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'
+
+/**
+ * Neutral ink at two strengths.
+ *
+ * `--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) 30%, transparent)',
+ cell: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)',
+ /**
+ * Mixed into `--bg` rather than `transparent`: a translucent ring lets the
+ * grid rules underneath show through its own stroke. Opaque, and darker than
+ * the ink it surrounds so it reads as chrome rather than more content.
+ */
+ selection: 'color-mix(in srgb, var(--text-secondary) 46%, var(--bg))',
+} as const
+
+/**
+ * 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]'
+
+const COLUMN_TEMPLATE = '88px 72px 72px 72px'
+
+const HEADER_WIDTHS = [36, 28, 26, 28] as const
+
+const CELL_WIDTHS = [
+ [54, 34, 26, 38],
+ [44, 30, 34, 30],
+ [60, 38, 22, 40],
+ [40, 26, 30, 34],
+ [50, 32, 28, 36],
+] as const
+
+/**
+ * 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
+
+/** Skeleton grid with one cell held in an edit ring, running off two edges. */
+function TablesGraphic() {
+ return (
+
+ )
+}
+
+const TABLES_DOCS_URL = 'https://docs.sim.ai/tables'
+
+interface TablesEmptyStateProps {
+ /** Creates a table — the same action the header's primary chip runs. */
+ onCreate: () => void
+ /** Mirrors the header chip's disabled state: no edit rights, or a create already in flight. */
+ createDisabled?: boolean
+}
+
+/** Empty state for the tables list when the workspace has none. */
+export function TablesEmptyState({ onCreate, createDisabled = false }: TablesEmptyStateProps) {
+ return (
+ }
+ title='Tables'
+ description='Create a table to store structured data your agents can read and write.'
+ action={
+ <>
+
+ New table
+
+
+ >
+ }
+ />
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.ts
new file mode 100644
index 00000000000..c9b8ece3273
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/is-resource-list-empty.ts
@@ -0,0 +1,65 @@
+interface ResourceListEmptyInput {
+ rowCount: number
+ /** The list query's first load. No rows yet means "not arrived", not "none exist". */
+ isLoading: boolean
+ /** The query is serving the previous key's rows while the new key refetches. */
+ isPlaceholderData: boolean
+ /** A failed load also leaves the list empty. */
+ error: unknown
+ /**
+ * The search term the rows are actually filtered by — the debounced value, never the
+ * instant URL one.
+ */
+ search: string
+ filterCount: number
+ /** The open folder, or `null` at the root. Omit for lists without folder navigation. */
+ folderId?: string | null
+ /**
+ * Whether the folder tree has resolved. Folder rows share the list with resource rows,
+ * so a workspace holding only folders looks empty until they arrive. Omit for lists
+ * without folder navigation.
+ */
+ foldersResolved?: boolean
+}
+
+/**
+ * Whether a resource list holds nothing, as opposed to merely showing nothing.
+ *
+ * A zero-data graphic invites someone to create their first item, so it may only
+ * appear when that is the true answer. Every other way `rows` empties out is a
+ * different message and gets gated here:
+ *
+ * - A search or filter that matched nothing, or an empty subfolder — the copy would
+ * be wrong. The search must be the debounced value the rows are filtered by;
+ * reading the instant URL value flashes the graphic for one debounce window after
+ * a search is cleared.
+ * - The list still arriving. Server prefetches are allowed to seed nothing, and the
+ * files list deliberately seeds nothing above 300 rows — so without this the
+ * emptiest-looking screen is shown to the fullest workspaces.
+ * - The query serving the previous key's rows. Filters are part of the query key and
+ * every list keeps previous data, so `isLoading` is false across a filter change.
+ * - A failed load, which is not an invitation to create anything.
+ * - The folder tree still resolving, for the same reason as the rows themselves: a
+ * workspace whose only contents are folders reads as empty until they land.
+ */
+export function isResourceListEmpty({
+ rowCount,
+ isLoading,
+ isPlaceholderData,
+ error,
+ search,
+ filterCount,
+ folderId = null,
+ foldersResolved = true,
+}: ResourceListEmptyInput): boolean {
+ return (
+ rowCount === 0 &&
+ !isLoading &&
+ !isPlaceholderData &&
+ !error &&
+ foldersResolved &&
+ folderId === null &&
+ !search.trim() &&
+ filterCount === 0
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
index 31ca47ae1bc..0742f9a93cc 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
@@ -225,6 +225,16 @@ 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.
+ *
+ * The table owns the growth box the slot is centred in, so any node centres —
+ * the slot does not have to carry its own `flex-1` to sit in the middle.
+ */
+ emptyState?: ReactNode
}
/**
@@ -263,10 +273,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 +371,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..b655e6f2d25 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
@@ -85,6 +85,8 @@ 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 { isResourceListEmpty } from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty'
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'
@@ -263,8 +265,19 @@ export function Files() {
}
}, [permissionConfig.hideFilesTab, router, workspaceId])
- const { data: files = EMPTY_WORKSPACE_FILES, isLoading, error } = useWorkspaceFiles(workspaceId)
- const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId)
+ const {
+ data: files = EMPTY_WORKSPACE_FILES,
+ isLoading,
+ isPlaceholderData,
+ error,
+ } = useWorkspaceFiles(workspaceId)
+ const {
+ data: folders = EMPTY_WORKSPACE_FILE_FOLDERS,
+ isSuccess: foldersLoaded,
+ isPlaceholderData: foldersArePlaceholder,
+ } = useWorkspaceFileFolders(workspaceId)
+ /** Matches `FolderNavigation.foldersResolved`, which the other resource pages read. */
+ const foldersResolved = foldersLoaded && !foldersArePlaceholder
const { data: members } = useWorkspaceMembersQuery(workspaceId)
const pinnedFileIds = usePinnedIds(workspaceId, 'file')
// Folders pin under their own resource type, so their pinned set is a separate query.
@@ -1804,6 +1817,17 @@ export function Files() {
return tags
}, [typeFilter, sizeFilter, uploadedByFilter, membersById])
+ const showEmptyState = isResourceListEmpty({
+ rowCount: rows.length,
+ isLoading,
+ isPlaceholderData,
+ error,
+ search: debouncedSearchTerm,
+ filterCount: filterTags.length,
+ folderId: currentFolderId,
+ foldersResolved,
+ })
+
if (fileIdFromRoute && !selectedFile && isLoading) {
return (
@@ -1904,6 +1928,14 @@ 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..32fbed78a31 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
@@ -54,6 +54,8 @@ 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 { isResourceListEmpty } from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty'
import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
import {
CreateBaseModal,
@@ -191,7 +193,7 @@ export function Knowledge() {
}
}, [permissionConfig.hideKnowledgeBaseTab, router, workspaceId])
- const { knowledgeBases, error } = useKnowledgeBasesList(workspaceId)
+ const { knowledgeBases, isLoading, isPlaceholderData, error } = useKnowledgeBasesList(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
/**
* Indexed once: `ownerCell` resolves a member per row, so passing the raw array makes the
@@ -1338,6 +1340,17 @@ export function Knowledge() {
return tags
}, [connectorFilter, contentFilter, ownerFilter, members])
+ const showEmptyState = isResourceListEmpty({
+ rowCount: rows.length,
+ isLoading,
+ isPlaceholderData,
+ error,
+ search: debouncedSearchQuery,
+ filterCount: filterTags.length,
+ folderId: currentFolderId,
+ foldersResolved,
+ })
+
return (
<>
@@ -1355,6 +1368,11 @@ 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..da75a34a8da 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
@@ -58,6 +58,8 @@ 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 { isResourceListEmpty } from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty'
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 +902,16 @@ export default function Logs() {
setTimeRange,
])
+ /** Logs has no folder navigation, so the graphic means "nothing has ever run here". */
+ const showEmptyState = isResourceListEmpty({
+ rowCount: rows.length,
+ isLoading: logsQuery.isLoading,
+ isPlaceholderData: logsQuery.isPlaceholderData,
+ error: logsQuery.error,
+ search: debouncedSearchQuery,
+ filterCount: filterTags.length,
+ })
+
const workflowsData = useMemo(
() =>
Object.values(allWorkflows).map((w) => ({
@@ -1191,6 +1203,7 @@ export default function Logs() {
) : (
: undefined}
virtualized
columns={LOG_COLUMNS}
rows={rows}
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
index dc5e89a8742..a7c309907b5 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
@@ -53,6 +53,8 @@ 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 { isResourceListEmpty } from '@/app/workspace/[workspaceId]/components/resource/is-resource-list-empty'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
@@ -135,7 +137,12 @@ export function Tables() {
// mutation service) invalidates the list so this view refetches without waiting for staleness.
useWorkspaceTablesRoom(workspaceId)
- const { data: tables = EMPTY_TABLES, error } = useTablesList(workspaceId)
+ const {
+ data: tables = EMPTY_TABLES,
+ isLoading,
+ isPlaceholderData,
+ error,
+ } = useTablesList(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
const pinnedTableIds = usePinnedIds(workspaceId, 'table')
// Folder pins live in their own `resourceType` namespace, so a page listing
@@ -731,6 +738,17 @@ export function Tables() {
return tags
}, [rowCountFilter, ownerFilter, membersById, setRowCountFilter, setOwnerFilter])
+ const showEmptyState = isResourceListEmpty({
+ rowCount: rows.length,
+ isLoading,
+ isPlaceholderData,
+ error,
+ search: debouncedSearchTerm,
+ filterCount: filterTags.length,
+ folderId: currentFolderId,
+ foldersResolved,
+ })
+
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
@@ -1267,6 +1285,14 @@ 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..538f740714e
--- /dev/null
+++ b/apps/sim/components/empty-state/empty-state.tsx
@@ -0,0 +1,27 @@
+import type { ReactNode } from 'react'
+
+interface EmptyStateProps {
+ title: string
+ description: string
+ graphic?: ReactNode
+ action?: ReactNode
+}
+
+/**
+ * Shared platform empty-state frame with a visual, concise guidance, and an optional action.
+ *
+ * The frame owns the action row's layout so every empty state's chips sit identically —
+ * callers pass the chips themselves, not a wrapper.
+ */
+export function EmptyState({ title, description, graphic, action }: EmptyStateProps) {
+ return (
+