From 87c36c760d381602becdbc0d2d0d73e809854fdc Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Fri, 13 Mar 2026 17:33:29 -0700
Subject: [PATCH 1/6] Add restore endpoints and ui
---
apps/sim/app/_styles/globals.css | 22 ++
.../app/api/knowledge/[id]/restore/route.ts | 61 ++++
.../app/api/table/[tableId]/restore/route.ts | 45 +++
.../app/api/workflows/[id]/restore/route.ts | 53 +++
.../[id]/files/[fileId]/restore/route.ts | 40 +++
.../workspace/[workspaceId]/files/files.tsx | 4 +-
.../[workspaceId]/knowledge/[id]/base.tsx | 8 +-
.../delete-knowledge-base-modal.tsx | 8 +-
.../[workspaceId]/settings/[section]/page.tsx | 1 +
.../settings/[section]/settings.tsx | 54 +--
.../recently-deleted/recently-deleted.tsx | 308 ++++++++++++++++++
.../[workspaceId]/settings/navigation.ts | 3 +
.../[tableId]/components/table/table.tsx | 4 +-
.../workspace/[workspaceId]/tables/tables.tsx | 6 +-
.../w/[workflowId]/components/panel/panel.tsx | 6 +-
.../components/delete-modal/delete-modal.tsx | 18 +-
apps/sim/components/emcn/components/index.ts | 1 +
.../emcn/components/toast/toast.tsx | 224 +++++++++++++
apps/sim/components/emcn/icons/index.ts | 1 +
.../components/emcn/icons/trash-outline.tsx | 28 ++
apps/sim/hooks/queries/kb/knowledge.ts | 18 +
apps/sim/hooks/queries/tables.ts | 18 +
apps/sim/hooks/queries/workflows.ts | 18 +
apps/sim/hooks/queries/workspace-files.ts | 21 ++
apps/sim/lib/knowledge/service.ts | 71 ++++
apps/sim/lib/table/service.ts | 29 ++
.../workspace/workspace-file-manager.ts | 35 ++
apps/sim/lib/workflows/lifecycle.ts | 73 +++++
28 files changed, 1139 insertions(+), 39 deletions(-)
create mode 100644 apps/sim/app/api/knowledge/[id]/restore/route.ts
create mode 100644 apps/sim/app/api/table/[tableId]/restore/route.ts
create mode 100644 apps/sim/app/api/workflows/[id]/restore/route.ts
create mode 100644 apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts
create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
create mode 100644 apps/sim/components/emcn/components/toast/toast.tsx
create mode 100644 apps/sim/components/emcn/icons/trash-outline.tsx
diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css
index 91b267d72d9..e6feaef5684 100644
--- a/apps/sim/app/_styles/globals.css
+++ b/apps/sim/app/_styles/globals.css
@@ -700,6 +700,28 @@ input[type="search"]::-ms-clear {
}
}
+@keyframes toast-enter {
+ from {
+ opacity: 0;
+ transform: translateY(8px) scale(0.97);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@keyframes toast-exit {
+ from {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: translateY(8px) scale(0.97);
+ }
+}
+
/**
* @depricated
* Legacy globals (light/dark) kept for backward-compat with old classes.
diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts
new file mode 100644
index 00000000000..f247d6aa276
--- /dev/null
+++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts
@@ -0,0 +1,61 @@
+import { db } from '@sim/db'
+import { knowledgeBase } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { restoreKnowledgeBase } from '@/lib/knowledge/service'
+import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
+
+const logger = createLogger('RestoreKnowledgeBaseAPI')
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const requestId = generateRequestId()
+ const { id } = await params
+
+ try {
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const [kb] = await db
+ .select({
+ id: knowledgeBase.id,
+ workspaceId: knowledgeBase.workspaceId,
+ userId: knowledgeBase.userId,
+ })
+ .from(knowledgeBase)
+ .where(eq(knowledgeBase.id, id))
+ .limit(1)
+
+ if (!kb) {
+ return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
+ }
+
+ if (kb.workspaceId) {
+ const permission = await getUserEntityPermissions(auth.userId, 'workspace', kb.workspaceId)
+ if (permission !== 'admin' && permission !== 'write') {
+ return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
+ }
+ } else if (kb.userId !== auth.userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ await restoreKnowledgeBase(id, requestId)
+
+ logger.info(`[${requestId}] Restored knowledge base ${id}`)
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ logger.error(`[${requestId}] Error restoring knowledge base ${id}`, error)
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Internal server error' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts
new file mode 100644
index 00000000000..8622f849f1d
--- /dev/null
+++ b/apps/sim/app/api/table/[tableId]/restore/route.ts
@@ -0,0 +1,45 @@
+import { createLogger } from '@sim/logger'
+import { type NextRequest, NextResponse } from 'next/server'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { getTableById, restoreTable } from '@/lib/table'
+import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
+
+const logger = createLogger('RestoreTableAPI')
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ tableId: string }> }
+) {
+ const requestId = generateRequestId()
+ const { tableId } = await params
+
+ try {
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ }
+
+ const table = await getTableById(tableId, { includeArchived: true })
+ if (!table) {
+ return NextResponse.json({ error: 'Table not found' }, { status: 404 })
+ }
+
+ const permission = await getUserEntityPermissions(auth.userId, 'workspace', table.workspaceId)
+ if (permission !== 'admin' && permission !== 'write') {
+ return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
+ }
+
+ await restoreTable(tableId, requestId)
+
+ logger.info(`[${requestId}] Restored table ${tableId}`)
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ logger.error(`[${requestId}] Error restoring table ${tableId}`, error)
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Internal server error' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts
new file mode 100644
index 00000000000..d50f95dea27
--- /dev/null
+++ b/apps/sim/app/api/workflows/[id]/restore/route.ts
@@ -0,0 +1,53 @@
+import { createLogger } from '@sim/logger'
+import { type NextRequest, NextResponse } from 'next/server'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { restoreWorkflow } from '@/lib/workflows/lifecycle'
+import { getWorkflowById } from '@/lib/workflows/utils'
+import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
+
+const logger = createLogger('RestoreWorkflowAPI')
+
+export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const requestId = generateRequestId()
+ const { id: workflowId } = await params
+
+ try {
+ const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!auth.success || !auth.userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const workflowData = await getWorkflowById(workflowId, { includeArchived: true })
+ if (!workflowData) {
+ return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
+ }
+
+ if (workflowData.workspaceId) {
+ const permission = await getUserEntityPermissions(
+ auth.userId,
+ 'workspace',
+ workflowData.workspaceId
+ )
+ if (permission !== 'admin' && permission !== 'write') {
+ return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
+ }
+ }
+
+ const result = await restoreWorkflow(workflowId, { requestId })
+
+ if (!result.restored) {
+ return NextResponse.json({ error: 'Workflow is not archived' }, { status: 400 })
+ }
+
+ logger.info(`[${requestId}] Restored workflow ${workflowId}`)
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ logger.error(`[${requestId}] Error restoring workflow ${workflowId}`, error)
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Internal server error' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts
new file mode 100644
index 00000000000..eae4bae4368
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts
@@ -0,0 +1,40 @@
+import { createLogger } from '@sim/logger'
+import { type NextRequest, NextResponse } from 'next/server'
+import { getSession } from '@/lib/auth'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { restoreWorkspaceFile } from '@/lib/uploads/contexts/workspace'
+import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
+
+const logger = createLogger('RestoreWorkspaceFileAPI')
+
+export async function POST(
+ request: NextRequest,
+ { params }: { params: Promise<{ id: string; fileId: string }> }
+) {
+ const requestId = generateRequestId()
+ const { id: workspaceId, fileId } = await params
+
+ try {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
+ if (userPermission !== 'admin' && userPermission !== 'write') {
+ return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
+ }
+
+ await restoreWorkspaceFile(workspaceId, fileId)
+
+ logger.info(`[${requestId}] Restored workspace file ${fileId}`)
+
+ return NextResponse.json({ success: true })
+ } catch (error) {
+ logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error)
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Internal server error' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
index 8cbc8014fc3..328c2b265b6 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
@@ -792,7 +792,9 @@ function DeleteConfirmModal({
Are you sure you want to delete{' '}
{fileName} ?{' '}
- This action cannot be undone.
+
+ You can restore it from Recently Deleted in Settings.
+
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
index 48d7e47c6c5..a772505bbc0 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
@@ -1105,9 +1105,11 @@ export function KnowledgeBase({
Are you sure you want to delete{' '}
{knowledgeBaseName} ?
- This will permanently delete the knowledge base and all {pagination.total} document
- {pagination.total === 1 ? '' : 's'} within it.{' '}
- This action cannot be undone.
+ The knowledge base and all {pagination.total} document
+ {pagination.total === 1 ? '' : 's'} within it will be removed.{' '}
+
+ You can restore it from Recently Deleted in Settings.
+
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx
index 4ce0610aa13..7d1655c3dc5 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/delete-knowledge-base-modal/delete-knowledge-base-modal.tsx
@@ -46,12 +46,14 @@ export function DeleteKnowledgeBaseModal({
<>
Are you sure you want to delete{' '}
{knowledgeBaseName} ?
- This will permanently remove all associated documents, chunks, and embeddings.
+ All associated documents, chunks, and embeddings will be removed.
>
) : (
- 'Are you sure you want to delete this knowledge base? This will permanently remove all associated documents, chunks, and embeddings.'
+ 'Are you sure you want to delete this knowledge base? All associated documents, chunks, and embeddings will be removed.'
)}{' '}
- This action cannot be undone.
+
+ You can restore it from Recently Deleted in Settings.
+
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
index 6a5ada99058..36c1f97867a 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
@@ -22,6 +22,7 @@ const SECTION_TITLES: Record = {
skills: 'Skills',
'workflow-mcp-servers': 'MCP Servers',
'credential-sets': 'Email Polling',
+ 'recently-deleted': 'Recently Deleted',
debug: 'Debug',
} as const
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
index fc8dbbaa664..e561302be54 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
@@ -2,7 +2,7 @@
import dynamic from 'next/dynamic'
import { useSearchParams } from 'next/navigation'
-import { Skeleton } from '@/components/emcn'
+import { Skeleton, ToastProvider } from '@/components/emcn'
import { ApiKeysSkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
import { BYOKSkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton'
import { CopilotSkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton'
@@ -135,6 +135,13 @@ const Debug = dynamic(
import('@/app/workspace/[workspaceId]/settings/components/debug/debug').then((m) => m.Debug),
{ loading: () => }
)
+const RecentlyDeleted = dynamic(
+ () =>
+ import(
+ '@/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted'
+ ).then((m) => m.RecentlyDeleted),
+ { loading: () => }
+)
const AccessControl = dynamic(
() => import('@/ee/access-control/components/access-control').then((m) => m.AccessControl),
{ loading: () => }
@@ -158,26 +165,29 @@ export function SettingsPage({ section }: SettingsPageProps) {
allNavigationItems.find((item) => item.id === effectiveSection)?.label ?? effectiveSection
return (
-
-
{label}
- {effectiveSection === 'general' &&
}
- {effectiveSection === 'integrations' &&
}
- {effectiveSection === 'secrets' &&
}
- {effectiveSection === 'template-profile' &&
}
- {effectiveSection === 'credential-sets' &&
}
- {effectiveSection === 'access-control' &&
}
- {effectiveSection === 'apikeys' &&
}
- {isBillingEnabled && effectiveSection === 'subscription' &&
}
- {isBillingEnabled && effectiveSection === 'team' &&
}
- {effectiveSection === 'sso' &&
}
- {effectiveSection === 'byok' &&
}
- {effectiveSection === 'copilot' &&
}
- {effectiveSection === 'mcp' &&
}
- {effectiveSection === 'custom-tools' &&
}
- {effectiveSection === 'skills' &&
}
- {effectiveSection === 'workflow-mcp-servers' &&
}
- {effectiveSection === 'inbox' &&
}
- {effectiveSection === 'debug' &&
}
-
+
+
+
{label}
+ {effectiveSection === 'general' &&
}
+ {effectiveSection === 'integrations' &&
}
+ {effectiveSection === 'secrets' &&
}
+ {effectiveSection === 'template-profile' &&
}
+ {effectiveSection === 'credential-sets' &&
}
+ {effectiveSection === 'access-control' &&
}
+ {effectiveSection === 'apikeys' &&
}
+ {isBillingEnabled && effectiveSection === 'subscription' &&
}
+ {isBillingEnabled && effectiveSection === 'team' &&
}
+ {effectiveSection === 'sso' &&
}
+ {effectiveSection === 'byok' &&
}
+ {effectiveSection === 'copilot' &&
}
+ {effectiveSection === 'mcp' &&
}
+ {effectiveSection === 'custom-tools' &&
}
+ {effectiveSection === 'skills' &&
}
+ {effectiveSection === 'workflow-mcp-servers' &&
}
+ {effectiveSection === 'inbox' &&
}
+ {effectiveSection === 'recently-deleted' &&
}
+ {effectiveSection === 'debug' &&
}
+
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
new file mode 100644
index 00000000000..c54612a9d24
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
@@ -0,0 +1,308 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import { Loader2, Search } from 'lucide-react'
+import { useParams, useRouter } from 'next/navigation'
+import {
+ Button,
+ SModalTabs,
+ SModalTabsList,
+ SModalTabsTrigger,
+ toast,
+ Undo,
+} from '@/components/emcn'
+import { Input } from '@/components/ui'
+import { formatDate } from '@/lib/core/utils/formatting'
+import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
+import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
+import { useKnowledgeBasesQuery, useRestoreKnowledgeBase } from '@/hooks/queries/kb/knowledge'
+import { useRestoreTable, useTablesList } from '@/hooks/queries/tables'
+import { useRestoreWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
+import { useRestoreWorkflow, useWorkflows } from '@/hooks/queries/workflows'
+
+function getResourceHref(workspaceId: string, type: Exclude, id: string): string {
+ const base = `/workspace/${workspaceId}`
+ switch (type) {
+ case 'workflow':
+ return `${base}/w/${id}`
+ case 'table':
+ return `${base}/tables/${id}`
+ case 'knowledge':
+ return `${base}/knowledge/${id}`
+ case 'file':
+ return `${base}/files`
+ }
+}
+
+type ResourceType = 'all' | 'workflow' | 'table' | 'knowledge' | 'file'
+
+const ICON_CLASS = 'h-[14px] w-[14px]'
+
+const RESOURCE_TYPE_TO_MOTHERSHIP: Record, MothershipResourceType> = {
+ workflow: 'workflow',
+ table: 'table',
+ knowledge: 'knowledgebase',
+ file: 'file',
+}
+
+interface DeletedResource {
+ id: string
+ name: string
+ type: Exclude
+ deletedAt: Date
+ workspaceId: string
+ color?: string
+}
+
+const TABS: { id: ResourceType; label: string }[] = [
+ { id: 'all', label: 'All' },
+ { id: 'workflow', label: 'Workflows' },
+ { id: 'table', label: 'Tables' },
+ { id: 'knowledge', label: 'Knowledge Bases' },
+ { id: 'file', label: 'Files' },
+]
+
+const TYPE_LABEL: Record, string> = {
+ workflow: 'Workflow',
+ table: 'Table',
+ knowledge: 'Knowledge Base',
+ file: 'File',
+}
+
+function ResourceIcon({ resource }: { resource: DeletedResource }) {
+ if (resource.type === 'workflow') {
+ const color = resource.color ?? '#888'
+ return (
+
+ )
+ }
+
+ const mothershipType = RESOURCE_TYPE_TO_MOTHERSHIP[resource.type]
+ const config = RESOURCE_REGISTRY[mothershipType]
+ return (
+ <>
+ {config.renderTabIcon(
+ { type: mothershipType, id: resource.id, title: resource.name },
+ ICON_CLASS
+ )}
+ >
+ )
+}
+
+export function RecentlyDeleted() {
+ const params = useParams()
+ const router = useRouter()
+ const workspaceId = params?.workspaceId as string
+ const [activeTab, setActiveTab] = useState('all')
+ const [searchTerm, setSearchTerm] = useState('')
+ const [restoringIds, setRestoringIds] = useState>(new Set())
+
+ const workflowsQuery = useWorkflows(workspaceId, { syncRegistry: false, scope: 'archived' })
+ const tablesQuery = useTablesList(workspaceId, 'archived')
+ const knowledgeQuery = useKnowledgeBasesQuery(workspaceId, { scope: 'archived' })
+ const filesQuery = useWorkspaceFiles(workspaceId, 'archived')
+
+ const restoreWorkflow = useRestoreWorkflow()
+ const restoreTable = useRestoreTable()
+ const restoreKnowledgeBase = useRestoreKnowledgeBase()
+ const restoreWorkspaceFile = useRestoreWorkspaceFile()
+
+ const isLoading =
+ workflowsQuery.isLoading ||
+ tablesQuery.isLoading ||
+ knowledgeQuery.isLoading ||
+ filesQuery.isLoading
+
+ const resources = useMemo(() => {
+ const items: DeletedResource[] = []
+
+ for (const wf of workflowsQuery.data ?? []) {
+ items.push({
+ id: wf.id,
+ name: wf.name,
+ type: 'workflow',
+ deletedAt: new Date(wf.lastModified),
+ workspaceId: wf.workspaceId ?? workspaceId,
+ color: wf.color,
+ })
+ }
+
+ for (const t of tablesQuery.data ?? []) {
+ items.push({
+ id: t.id,
+ name: t.name,
+ type: 'table',
+ deletedAt: new Date(t.archivedAt ?? t.updatedAt),
+ workspaceId: t.workspaceId,
+ })
+ }
+
+ for (const kb of knowledgeQuery.data ?? []) {
+ items.push({
+ id: kb.id,
+ name: kb.name,
+ type: 'knowledge',
+ deletedAt: new Date(kb.updatedAt),
+ workspaceId: kb.workspaceId ?? workspaceId,
+ })
+ }
+
+ for (const f of filesQuery.data ?? []) {
+ items.push({
+ id: f.id,
+ name: f.name,
+ type: 'file',
+ deletedAt: new Date(f.deletedAt ?? f.uploadedAt),
+ workspaceId: f.workspaceId,
+ })
+ }
+
+ items.sort((a, b) => b.deletedAt.getTime() - a.deletedAt.getTime())
+ return items
+ }, [workflowsQuery.data, tablesQuery.data, knowledgeQuery.data, filesQuery.data, workspaceId])
+
+ const filtered = useMemo(() => {
+ let items = activeTab === 'all' ? resources : resources.filter((r) => r.type === activeTab)
+ if (searchTerm.trim()) {
+ const normalized = searchTerm.toLowerCase()
+ items = items.filter((r) => r.name.toLowerCase().includes(normalized))
+ }
+ return items
+ }, [resources, activeTab, searchTerm])
+
+ const showNoResults = searchTerm.trim() && filtered.length === 0 && resources.length > 0
+
+ function handleRestore(resource: DeletedResource) {
+ setRestoringIds((prev) => new Set(prev).add(resource.id))
+
+ const onSettled = () => {
+ setRestoringIds((prev) => {
+ const next = new Set(prev)
+ next.delete(resource.id)
+ return next
+ })
+ }
+
+ const onSuccess = () => {
+ const href = getResourceHref(resource.workspaceId, resource.type, resource.id)
+ toast.success(`${resource.name} restored`, {
+ action: { label: 'View', onClick: () => router.push(href) },
+ })
+ }
+
+ switch (resource.type) {
+ case 'workflow':
+ restoreWorkflow.mutate(resource.id, { onSettled, onSuccess })
+ break
+ case 'table':
+ restoreTable.mutate(resource.id, { onSettled, onSuccess })
+ break
+ case 'knowledge':
+ restoreKnowledgeBase.mutate(resource.id, { onSettled, onSuccess })
+ break
+ case 'file':
+ restoreWorkspaceFile.mutate(
+ { workspaceId: resource.workspaceId, fileId: resource.id },
+ { onSettled, onSuccess }
+ )
+ break
+ }
+ }
+
+ return (
+
+
+ Items you delete are kept here for 30 days before being permanently removed.
+
+
+
+
+ setSearchTerm(e.target.value)}
+ disabled={isLoading}
+ className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0'
+ />
+
+
+
setActiveTab(v as ResourceType)}
+ >
+
+ {TABS.map((tab) => (
+
+ {tab.label}
+
+ ))}
+
+
+
+ {isLoading ? (
+
+
+
+ ) : filtered.length === 0 ? (
+
+
+ {showNoResults
+ ? `No items found matching \u201c${searchTerm}\u201d`
+ : 'No deleted items'}
+
+
+ ) : (
+
+ {filtered.map((resource) => {
+ const isRestoring = restoringIds.has(resource.id)
+
+ return (
+
+
+
+
+
+ {resource.name}
+
+
+ {TYPE_LABEL[resource.type]}
+ {' \u00b7 '}
+ Deleted {formatDate(resource.deletedAt)}
+
+
+
+
handleRestore(resource)}
+ className='shrink-0'
+ >
+ {isRestoring ? (
+
+ ) : (
+
+ )}
+ Restore
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
index 59f305c6913..482d7d9a98a 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
@@ -12,6 +12,7 @@ import {
Settings,
ShieldCheck,
TerminalWindow,
+ TrashOutline,
User,
Users,
Wrench,
@@ -39,6 +40,7 @@ export type SettingsSection =
| 'inbox'
| 'docs'
| 'debug'
+ | 'recently-deleted'
export type NavigationSection =
| 'account'
@@ -143,6 +145,7 @@ export const allNavigationItems: NavigationItem[] = [
requiresHosted: true,
selfHostedOverride: isCredentialSetsEnabled,
},
+ { id: 'recently-deleted', label: 'Recently Deleted', icon: TrashOutline, section: 'system' },
{
id: 'sso',
label: 'Single Sign-On',
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx
index b0554883206..db4aac84403 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx
@@ -1454,7 +1454,9 @@ export function Table({
Are you sure you want to delete{' '}
{tableData?.name} ?{' '}
- This action cannot be undone.
+
+ You can restore it from Recently Deleted in Settings.
+
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
index 4a22fdfdb3a..e32c890e00b 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
@@ -211,8 +211,10 @@ export function Tables() {
Are you sure you want to delete{' '}
{activeTable?.name} ?
- This will permanently delete all {activeTable?.rowCount} rows.{' '}
- This action cannot be undone.
+ All {activeTable?.rowCount} rows will be removed.{' '}
+
+ You can restore it from Recently Deleted in Settings.
+
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
index 816768e7294..eee761a21f2 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx
@@ -590,8 +590,10 @@ export const Panel = memo(function Panel() {
{currentWorkflow?.name ?? 'this workflow'}
- ? This will permanently remove all associated blocks, executions, and configuration.{' '}
- This action cannot be undone.
+ ? All associated blocks, executions, and configuration will be removed.{' '}
+
+ You can restore it from Recently Deleted in Settings.
+
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx
index 47ce7a31227..7773ae13720 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal.tsx
@@ -64,6 +64,8 @@ export function DeleteModal({
title = 'Delete Workspace'
}
+ const restorableTypes = new Set(['workflow'])
+
const renderDescription = () => {
if (itemType === 'workflow') {
if (isMultiple) {
@@ -73,7 +75,7 @@ export function DeleteModal({
{displayNames.join(', ')}
- ? This will permanently remove all associated blocks, executions, and configuration.
+ ? All associated blocks, executions, and configuration will be removed.
>
)
}
@@ -81,12 +83,12 @@ export function DeleteModal({
return (
<>
Are you sure you want to delete{' '}
- {displayNames[0]} ? This
- will permanently remove all associated blocks, executions, and configuration.
+ {displayNames[0]} ? All
+ associated blocks, executions, and configuration will be removed.
>
)
}
- return 'Are you sure you want to delete this workflow? This will permanently remove all associated blocks, executions, and configuration.'
+ return 'Are you sure you want to delete this workflow? All associated blocks, executions, and configuration will be removed.'
}
if (itemType === 'folder') {
@@ -174,7 +176,13 @@ export function DeleteModal({
{renderDescription()}{' '}
- This action cannot be undone.
+ {restorableTypes.has(itemType) ? (
+
+ You can restore it from Recently Deleted in Settings.
+
+ ) : (
+ This action cannot be undone.
+ )}
diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts
index dcaefa1082b..dad5a5459b4 100644
--- a/apps/sim/components/emcn/components/index.ts
+++ b/apps/sim/components/emcn/components/index.ts
@@ -141,5 +141,6 @@ export {
tagVariants,
} from './tag-input/tag-input'
export { Textarea } from './textarea/textarea'
+export { toast, ToastProvider, useToast } from './toast/toast'
export { TimePicker, type TimePickerProps, timePickerVariants } from './time-picker/time-picker'
export { Tooltip } from './tooltip/tooltip'
diff --git a/apps/sim/components/emcn/components/toast/toast.tsx b/apps/sim/components/emcn/components/toast/toast.tsx
new file mode 100644
index 00000000000..c392ec38a47
--- /dev/null
+++ b/apps/sim/components/emcn/components/toast/toast.tsx
@@ -0,0 +1,224 @@
+'use client'
+
+import {
+ type ReactNode,
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+} from 'react'
+import { createPortal } from 'react-dom'
+import { X } from 'lucide-react'
+import { cn } from '@/lib/core/utils/cn'
+
+const AUTO_DISMISS_MS = 0
+const EXIT_ANIMATION_MS = 200
+const MAX_VISIBLE = 20
+
+type ToastVariant = 'default' | 'success' | 'error'
+
+interface ToastAction {
+ label: string
+ onClick: () => void
+}
+
+interface ToastData {
+ id: string
+ message: string
+ description?: string
+ variant: ToastVariant
+ action?: ToastAction
+ duration: number
+}
+
+type ToastInput = {
+ message: string
+ description?: string
+ variant?: ToastVariant
+ action?: ToastAction
+ duration?: number
+}
+
+type ToastFn = {
+ (input: ToastInput): string
+ success: (message: string, options?: Omit) => string
+ error: (message: string, options?: Omit) => string
+}
+
+interface ToastContextValue {
+ toast: ToastFn
+ dismiss: (id: string) => void
+}
+
+const ToastContext = createContext(null)
+
+let globalToast: ToastFn | null = null
+
+function createToastFn(add: (input: ToastInput) => string): ToastFn {
+ const fn = ((input: ToastInput) => add(input)) as ToastFn
+ fn.success = (message, options) => add({ ...options, message, variant: 'success' })
+ fn.error = (message, options) => add({ ...options, message, variant: 'error' })
+ return fn
+}
+
+/**
+ * Imperative toast function. Requires `` to be mounted.
+ *
+ * @example
+ * ```tsx
+ * toast.success('Item restored', { action: { label: 'View', onClick: () => router.push('/item') } })
+ * toast.error('Something went wrong')
+ * toast({ message: 'Hello', variant: 'default' })
+ * ```
+ */
+export const toast: ToastFn = createToastFn((input) => {
+ if (!globalToast) {
+ throw new Error('toast() called before mounted')
+ }
+ return globalToast(input)
+})
+
+/**
+ * Hook to access the toast function from context.
+ */
+export function useToast() {
+ const ctx = useContext(ToastContext)
+ if (!ctx) throw new Error('useToast must be used within ')
+ return ctx
+}
+
+const VARIANT_STYLES: Record = {
+ default: 'border-[var(--border)] bg-[var(--bg)] text-[var(--text-primary)]',
+ success:
+ 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/40 dark:bg-emerald-950/30 dark:text-emerald-200',
+ error:
+ 'border-red-200 bg-red-50 text-red-900 dark:border-red-800/40 dark:bg-red-950/30 dark:text-red-200',
+}
+
+function ToastItem({
+ toast: t,
+ onDismiss,
+}: {
+ toast: ToastData
+ onDismiss: (id: string) => void
+}) {
+ const [exiting, setExiting] = useState(false)
+ const timerRef = useRef>(undefined)
+
+ const dismiss = useCallback(() => {
+ setExiting(true)
+ setTimeout(() => onDismiss(t.id), EXIT_ANIMATION_MS)
+ }, [onDismiss, t.id])
+
+ useEffect(() => {
+ if (t.duration > 0) {
+ timerRef.current = setTimeout(dismiss, t.duration)
+ return () => clearTimeout(timerRef.current)
+ }
+ }, [dismiss, t.duration])
+
+ return (
+
+
+
{t.message}
+ {t.description && (
+
{t.description}
+ )}
+
+ {t.action && (
+
{
+ t.action!.onClick()
+ dismiss()
+ }}
+ className='shrink-0 text-[13px] font-medium underline underline-offset-2 opacity-90 hover:opacity-100'
+ >
+ {t.action.label}
+
+ )}
+
+
+
+
+ )
+}
+
+/**
+ * Toast container that renders toasts via portal.
+ * Mount once in your root layout.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function ToastProvider({ children }: { children?: ReactNode }) {
+ const [toasts, setToasts] = useState([])
+ const [mounted, setMounted] = useState(false)
+
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ const addToast = useCallback((input: ToastInput): string => {
+ const id = crypto.randomUUID()
+ const data: ToastData = {
+ id,
+ message: input.message,
+ description: input.description,
+ variant: input.variant ?? 'default',
+ action: input.action,
+ duration: input.duration ?? AUTO_DISMISS_MS,
+ }
+ setToasts((prev) => [...prev, data].slice(-MAX_VISIBLE))
+ return id
+ }, [])
+
+ const dismissToast = useCallback((id: string) => {
+ setToasts((prev) => prev.filter((t) => t.id !== id))
+ }, [])
+
+ const toastFn = useRef(createToastFn(addToast))
+
+ useEffect(() => {
+ toastFn.current = createToastFn(addToast)
+ globalToast = toastFn.current
+ return () => {
+ globalToast = null
+ }
+ }, [addToast])
+
+ const ctx: ToastContextValue = { toast: toastFn.current, dismiss: dismissToast }
+
+ return (
+
+ {children}
+ {mounted &&
+ createPortal(
+
+ {toasts.map((t) => (
+
+ ))}
+
,
+ document.body
+ )}
+
+ )
+}
diff --git a/apps/sim/components/emcn/icons/index.ts b/apps/sim/components/emcn/icons/index.ts
index f573411bf64..4c89ce8ea95 100644
--- a/apps/sim/components/emcn/icons/index.ts
+++ b/apps/sim/components/emcn/icons/index.ts
@@ -71,6 +71,7 @@ export { Tag } from './tag'
export { TerminalWindow } from './terminal-window'
export { Trash } from './trash'
export { Trash2 } from './trash2'
+export { TrashOutline } from './trash-outline'
export { TypeBoolean } from './type-boolean'
export { TypeJson } from './type-json'
export { TypeNumber } from './type-number'
diff --git a/apps/sim/components/emcn/icons/trash-outline.tsx b/apps/sim/components/emcn/icons/trash-outline.tsx
new file mode 100644
index 00000000000..075574ae4b5
--- /dev/null
+++ b/apps/sim/components/emcn/icons/trash-outline.tsx
@@ -0,0 +1,28 @@
+import type { SVGProps } from 'react'
+
+/**
+ * Outline-style trash icon matching the stroke-based nav icon convention
+ * @param props - SVG properties including className, fill, etc.
+ */
+export function TrashOutline(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts
index 44360e0f25b..28bd6a05596 100644
--- a/apps/sim/hooks/queries/kb/knowledge.ts
+++ b/apps/sim/hooks/queries/kb/knowledge.ts
@@ -1219,6 +1219,24 @@ export async function deleteDocumentTagDefinitions({
}
}
+export function useRestoreKnowledgeBase() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (knowledgeBaseId: string) => {
+ const res = await fetch(`/api/knowledge/${knowledgeBaseId}/restore`, { method: 'POST' })
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}))
+ throw new Error(data.error || 'Failed to restore knowledge base')
+ }
+ return res.json()
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: knowledgeKeys.all })
+ },
+ })
+}
+
export function useDeleteDocumentTagDefinitions() {
const queryClient = useQueryClient()
diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts
index 984f5ec88cd..2c75b3f378a 100644
--- a/apps/sim/hooks/queries/tables.ts
+++ b/apps/sim/hooks/queries/tables.ts
@@ -716,6 +716,24 @@ export function useUpdateTableMetadata({ workspaceId, tableId }: RowMutationCont
/**
* Delete a column from a table.
*/
+export function useRestoreTable() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (tableId: string) => {
+ const res = await fetch(`/api/table/${tableId}/restore`, { method: 'POST' })
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}))
+ throw new Error(data.error || 'Failed to restore table')
+ }
+ return res.json()
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
+ },
+ })
+}
+
export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) {
const queryClient = useQueryClient()
diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts
index 95070070a90..d3181becfb2 100644
--- a/apps/sim/hooks/queries/workflows.ts
+++ b/apps/sim/hooks/queries/workflows.ts
@@ -767,3 +767,21 @@ export function useImportWorkflow() {
},
})
}
+
+export function useRestoreWorkflow() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async (workflowId: string) => {
+ const res = await fetch(`/api/workflows/${workflowId}/restore`, { method: 'POST' })
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}))
+ throw new Error(data.error || 'Failed to restore workflow')
+ }
+ return res.json()
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: workflowKeys.lists() })
+ },
+ })
+}
diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts
index 5c543191e19..224f074f7ad 100644
--- a/apps/sim/hooks/queries/workspace-files.ts
+++ b/apps/sim/hooks/queries/workspace-files.ts
@@ -310,3 +310,24 @@ export function useDeleteWorkspaceFile() {
},
})
}
+
+export function useRestoreWorkspaceFile() {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({ workspaceId, fileId }: { workspaceId: string; fileId: string }) => {
+ const res = await fetch(`/api/workspaces/${workspaceId}/files/${fileId}/restore`, {
+ method: 'POST',
+ })
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}))
+ throw new Error(data.error || 'Failed to restore file')
+ }
+ return res.json()
+ },
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() })
+ queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() })
+ },
+ })
+}
diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts
index 58af6e310da..cb4169e5705 100644
--- a/apps/sim/lib/knowledge/service.ts
+++ b/apps/sim/lib/knowledge/service.ts
@@ -366,3 +366,74 @@ export async function deleteKnowledgeBase(
logger.info(`[${requestId}] Soft deleted knowledge base: ${knowledgeBaseId}`)
}
+
+/**
+ * Restore a soft-deleted knowledge base and its graph children.
+ * Clears archivedAt on children that were archived as part of the KB snapshot.
+ * Does NOT revive children that were directly deleted (deletedAt set).
+ */
+export async function restoreKnowledgeBase(
+ knowledgeBaseId: string,
+ requestId: string
+): Promise {
+ const [kb] = await db
+ .select({
+ id: knowledgeBase.id,
+ deletedAt: knowledgeBase.deletedAt,
+ workspaceId: knowledgeBase.workspaceId,
+ })
+ .from(knowledgeBase)
+ .where(eq(knowledgeBase.id, knowledgeBaseId))
+ .limit(1)
+
+ if (!kb) {
+ throw new Error('Knowledge base not found')
+ }
+
+ if (!kb.deletedAt) {
+ throw new Error('Knowledge base is not archived')
+ }
+
+ if (kb.workspaceId) {
+ const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
+ const ws = await getWorkspaceWithOwner(kb.workspaceId)
+ if (!ws || ws.archivedAt) {
+ throw new Error('Cannot restore knowledge base into an archived workspace')
+ }
+ }
+
+ const now = new Date()
+
+ await db.transaction(async (tx) => {
+ await tx.execute(sql`SELECT 1 FROM knowledge_base WHERE id = ${knowledgeBaseId} FOR UPDATE`)
+
+ await tx
+ .update(knowledgeBase)
+ .set({ deletedAt: null, updatedAt: now })
+ .where(eq(knowledgeBase.id, knowledgeBaseId))
+
+ await tx
+ .update(document)
+ .set({ archivedAt: null })
+ .where(
+ and(
+ eq(document.knowledgeBaseId, knowledgeBaseId),
+ isNotNull(document.archivedAt),
+ isNull(document.deletedAt)
+ )
+ )
+
+ await tx
+ .update(knowledgeConnector)
+ .set({ archivedAt: null, updatedAt: now })
+ .where(
+ and(
+ eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
+ isNotNull(knowledgeConnector.archivedAt),
+ isNull(knowledgeConnector.deletedAt)
+ )
+ )
+ })
+
+ logger.info(`[${requestId}] Restored knowledge base: ${knowledgeBaseId}`)
+}
diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts
index 23344dc6e48..e4f25689f19 100644
--- a/apps/sim/lib/table/service.ts
+++ b/apps/sim/lib/table/service.ts
@@ -432,6 +432,35 @@ export async function deleteTable(tableId: string, requestId: string): Promise {
+ const table = await getTableById(tableId, { includeArchived: true })
+ if (!table) {
+ throw new Error('Table not found')
+ }
+
+ if (!table.archivedAt) {
+ throw new Error('Table is not archived')
+ }
+
+ if (table.workspaceId) {
+ const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
+ const ws = await getWorkspaceWithOwner(table.workspaceId)
+ if (!ws || ws.archivedAt) {
+ throw new Error('Cannot restore table into an archived workspace')
+ }
+ }
+
+ await db
+ .update(userTableDefinitions)
+ .set({ archivedAt: null, updatedAt: new Date() })
+ .where(eq(userTableDefinitions.id, tableId))
+
+ logger.info(`[${requestId}] Restored table ${tableId}`)
+}
+
/**
* Inserts a single row into a table.
*
diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts
index afc5e7918a1..af513dfe0f9 100644
--- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts
+++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts
@@ -526,3 +526,38 @@ export async function deleteWorkspaceFile(workspaceId: string, fileId: string):
)
}
}
+
+/**
+ * Restore a soft-deleted workspace file.
+ */
+export async function restoreWorkspaceFile(workspaceId: string, fileId: string): Promise {
+ logger.info(`Restoring workspace file: ${fileId}`)
+
+ const fileRecord = await getWorkspaceFile(workspaceId, fileId, { includeDeleted: true })
+ if (!fileRecord) {
+ throw new Error('File not found')
+ }
+
+ if (!fileRecord.deletedAt) {
+ throw new Error('File is not archived')
+ }
+
+ const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
+ const ws = await getWorkspaceWithOwner(workspaceId)
+ if (!ws || ws.archivedAt) {
+ throw new Error('Cannot restore file into an archived workspace')
+ }
+
+ await db
+ .update(workspaceFiles)
+ .set({ deletedAt: null })
+ .where(
+ and(
+ eq(workspaceFiles.id, fileId),
+ eq(workspaceFiles.workspaceId, workspaceId),
+ eq(workspaceFiles.context, 'workspace')
+ )
+ )
+
+ logger.info(`Successfully restored workspace file: ${fileRecord.name}`)
+}
diff --git a/apps/sim/lib/workflows/lifecycle.ts b/apps/sim/lib/workflows/lifecycle.ts
index 4de83dac6eb..c5ecbb639c7 100644
--- a/apps/sim/lib/workflows/lifecycle.ts
+++ b/apps/sim/lib/workflows/lifecycle.ts
@@ -231,6 +231,79 @@ export async function archiveWorkflow(
}
}
+interface RestoreWorkflowOptions {
+ requestId: string
+}
+
+export async function restoreWorkflow(
+ workflowId: string,
+ options: RestoreWorkflowOptions
+): Promise<{ restored: boolean; workflow: Awaited> | null }> {
+ const existingWorkflow = await getWorkflowById(workflowId, { includeArchived: true })
+
+ if (!existingWorkflow) {
+ return { restored: false, workflow: null }
+ }
+
+ if (!existingWorkflow.archivedAt) {
+ return { restored: false, workflow: existingWorkflow }
+ }
+
+ if (existingWorkflow.workspaceId) {
+ const { getWorkspaceWithOwner } = await import('@/lib/workspaces/permissions/utils')
+ const ws = await getWorkspaceWithOwner(existingWorkflow.workspaceId)
+ if (!ws || ws.archivedAt) {
+ throw new Error('Cannot restore workflow into an archived workspace')
+ }
+ }
+
+ const now = new Date()
+
+ await db.transaction(async (tx) => {
+ await tx
+ .update(workflow)
+ .set({ archivedAt: null, updatedAt: now })
+ .where(eq(workflow.id, workflowId))
+
+ await tx
+ .update(workflowSchedule)
+ .set({ archivedAt: null, updatedAt: now })
+ .where(eq(workflowSchedule.workflowId, workflowId))
+
+ await tx
+ .update(webhook)
+ .set({ archivedAt: null, updatedAt: now })
+ .where(eq(webhook.workflowId, workflowId))
+
+ await tx
+ .update(chat)
+ .set({ archivedAt: null, updatedAt: now })
+ .where(eq(chat.workflowId, workflowId))
+
+ await tx
+ .update(form)
+ .set({ archivedAt: null, updatedAt: now })
+ .where(eq(form.workflowId, workflowId))
+
+ await tx
+ .update(workflowMcpTool)
+ .set({ archivedAt: null, updatedAt: now })
+ .where(eq(workflowMcpTool.workflowId, workflowId))
+
+ await tx
+ .update(a2aAgent)
+ .set({ archivedAt: null, updatedAt: now })
+ .where(eq(a2aAgent.workflowId, workflowId))
+ })
+
+ logger.info(`[${options.requestId}] Restored workflow ${workflowId}`)
+
+ return {
+ restored: true,
+ workflow: await getWorkflowById(workflowId),
+ }
+}
+
export async function archiveWorkflows(
workflowIds: string[],
options: ArchiveWorkflowOptions
From 67011c996ee2294d54c88664db144398b43828f9 Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Fri, 13 Mar 2026 17:55:13 -0700
Subject: [PATCH 2/6] Derive toast from notification
---
apps/sim/app/_styles/globals.css | 21 --
.../app/workspace/[workspaceId]/layout.tsx | 5 +-
.../settings/[section]/settings.tsx | 10 +-
.../recently-deleted/recently-deleted.tsx | 2 +
.../notifications/notifications.tsx | 300 ++++--------------
.../emcn/components/toast/toast.tsx | 290 ++++++++++++-----
6 files changed, 295 insertions(+), 333 deletions(-)
diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css
index e6feaef5684..a50c64c31a9 100644
--- a/apps/sim/app/_styles/globals.css
+++ b/apps/sim/app/_styles/globals.css
@@ -700,27 +700,6 @@ input[type="search"]::-ms-clear {
}
}
-@keyframes toast-enter {
- from {
- opacity: 0;
- transform: translateY(8px) scale(0.97);
- }
- to {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
-}
-
-@keyframes toast-exit {
- from {
- opacity: 1;
- transform: translateY(0) scale(1);
- }
- to {
- opacity: 0;
- transform: translateY(8px) scale(0.97);
- }
-}
/**
* @depricated
diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx
index de24323dd85..5abbde90ab6 100644
--- a/apps/sim/app/workspace/[workspaceId]/layout.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx
@@ -1,5 +1,6 @@
'use client'
+import { ToastProvider } from '@/components/emcn'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -8,7 +9,7 @@ import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/side
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
return (
- <>
+
@@ -25,6 +26,6 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
- >
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
index e561302be54..41fe0eb890a 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
@@ -2,7 +2,7 @@
import dynamic from 'next/dynamic'
import { useSearchParams } from 'next/navigation'
-import { Skeleton, ToastProvider } from '@/components/emcn'
+import { Skeleton } from '@/components/emcn'
import { ApiKeysSkeleton } from '@/app/workspace/[workspaceId]/settings/components/api-keys/api-key-skeleton'
import { BYOKSkeleton } from '@/app/workspace/[workspaceId]/settings/components/byok/byok-skeleton'
import { CopilotSkeleton } from '@/app/workspace/[workspaceId]/settings/components/copilot/copilot-skeleton'
@@ -165,9 +165,8 @@ export function SettingsPage({ section }: SettingsPageProps) {
allNavigationItems.find((item) => item.id === effectiveSection)?.label ?? effectiveSection
return (
-
-
-
{label}
+
+
{label}
{effectiveSection === 'general' && }
{effectiveSection === 'integrations' && }
{effectiveSection === 'secrets' && }
@@ -187,7 +186,6 @@ export function SettingsPage({ section }: SettingsPageProps) {
{effectiveSection === 'inbox' && }
{effectiveSection === 'recently-deleted' && }
{effectiveSection === 'debug' && }
-
-
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
index c54612a9d24..44c60dc85a1 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
@@ -5,6 +5,7 @@ import { Loader2, Search } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
Button,
+ Check,
SModalTabs,
SModalTabsList,
SModalTabsTrigger,
@@ -193,6 +194,7 @@ export function RecentlyDeleted() {
const onSuccess = () => {
const href = getResourceHref(resource.workspaceId, resource.type, resource.id)
toast.success(`${resource.name} restored`, {
+ icon: ,
action: { label: 'View', onClick: () => router.push(href) },
})
}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx
index 0dd388178d4..db46e4dd540 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/notifications/notifications.tsx
@@ -1,10 +1,8 @@
-import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { memo, useCallback, useEffect, useRef } from 'react'
import { createLogger } from '@sim/logger'
-import { X } from 'lucide-react'
-import { Button, Tooltip } from '@/components/emcn'
+import { toast, useToast } from '@/components/emcn'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
-import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
type Notification,
type NotificationAction,
@@ -14,13 +12,6 @@ import {
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
const logger = createLogger('Notifications')
-const MAX_VISIBLE_NOTIFICATIONS = 4
-const STACK_OFFSET_PX = 3
-const AUTO_DISMISS_MS = 10000
-const EXIT_ANIMATION_MS = 200
-
-const RING_RADIUS = 5.5
-const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
const ACTION_LABELS: Record = {
copilot: 'Fix in Copilot',
@@ -28,120 +19,99 @@ const ACTION_LABELS: Record = {
'unlock-workflow': 'Unlock Workflow',
} as const
-function isAutoDismissable(n: Notification): boolean {
- return n.level === 'error' && !!n.workflowId
+function executeNotificationAction(action: NotificationAction) {
+ switch (action.type) {
+ case 'copilot':
+ openCopilotWithMessage(action.message)
+ break
+ case 'refresh':
+ window.location.reload()
+ break
+ case 'unlock-workflow':
+ window.dispatchEvent(new CustomEvent('unlock-workflow'))
+ break
+ default:
+ logger.warn('Unknown action type', { actionType: action.type })
+ }
}
-function CountdownRing({ onPause }: { onPause: () => void }) {
- return (
-
-
-
-
-
-
-
-
-
-
- Keep visible
-
-
- )
+function notificationToToast(n: Notification, removeNotification: (id: string) => void) {
+ const toastAction = n.action
+ ? {
+ label: ACTION_LABELS[n.action.type] ?? 'Take action',
+ onClick: () => {
+ executeNotificationAction(n.action!)
+ removeNotification(n.id)
+ },
+ }
+ : undefined
+
+ return {
+ message: n.message,
+ variant: n.level === 'error' ? ('error' as const) : ('default' as const),
+ action: toastAction,
+ duration: n.level === 'error' && n.workflowId ? 10_000 : 0,
+ }
}
/**
- * Notifications display component.
- * Positioned in the bottom-right workspace area, reactive to panel width and terminal height.
- * Shows both global notifications and workflow-specific notifications.
+ * Headless bridge that syncs the notification Zustand store into the toast system.
*
- * Workflow error notifications auto-dismiss after {@link AUTO_DISMISS_MS}ms with a countdown
- * ring. Clicking the ring pauses all timers until the notification stack clears.
+ * Watches for new notifications scoped to the active workflow and shows them as toasts.
+ * When a toast is dismissed, the corresponding notification is removed from the store.
*/
export const Notifications = memo(function Notifications() {
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
-
const allNotifications = useNotificationStore((state) => state.notifications)
const removeNotification = useNotificationStore((state) => state.removeNotification)
const clearNotifications = useNotificationStore((state) => state.clearNotifications)
+ const { dismissAll } = useToast()
- const visibleNotifications = useMemo(() => {
- if (!activeWorkflowId) return []
- return allNotifications
- .filter((n) => !n.workflowId || n.workflowId === activeWorkflowId)
- .slice(0, MAX_VISIBLE_NOTIFICATIONS)
- }, [allNotifications, activeWorkflowId])
+ const shownIdsRef = useRef(new Set())
- /**
- * Executes a notification action and handles side effects.
- *
- * @param notificationId - The ID of the notification whose action is executed.
- * @param action - The action configuration to execute.
- */
- const executeAction = useCallback(
- (notificationId: string, action: NotificationAction) => {
- try {
- logger.info('Executing notification action', {
- notificationId,
- actionType: action.type,
- messageLength: action.message.length,
- })
+ const showNotification = useCallback(
+ (n: Notification) => {
+ if (shownIdsRef.current.has(n.id)) return
+ shownIdsRef.current.add(n.id)
- switch (action.type) {
- case 'copilot':
- openCopilotWithMessage(action.message)
- break
- case 'refresh':
- window.location.reload()
- break
- case 'unlock-workflow':
- window.dispatchEvent(new CustomEvent('unlock-workflow'))
- break
- default:
- logger.warn('Unknown action type', { notificationId, actionType: action.type })
- }
+ const input = notificationToToast(n, removeNotification)
+ toast(input)
- removeNotification(notificationId)
- } catch (error) {
- logger.error('Failed to execute notification action', {
- notificationId,
- actionType: action.type,
- error,
- })
- }
+ logger.info('Notification shown as toast', {
+ id: n.id,
+ level: n.level,
+ workflowId: n.workflowId,
+ })
},
[removeNotification]
)
+ useEffect(() => {
+ if (!activeWorkflowId) return
+
+ const visible = allNotifications.filter(
+ (n) => !n.workflowId || n.workflowId === activeWorkflowId
+ )
+
+ for (const n of visible) {
+ showNotification(n)
+ }
+
+ const currentIds = new Set(allNotifications.map((n) => n.id))
+ for (const id of shownIdsRef.current) {
+ if (!currentIds.has(id)) {
+ shownIdsRef.current.delete(id)
+ }
+ }
+ }, [allNotifications, activeWorkflowId, showNotification])
+
useRegisterGlobalCommands(() =>
createCommands([
{
id: 'clear-notifications',
handler: () => {
clearNotifications(activeWorkflowId ?? undefined)
+ dismissAll()
},
overrides: {
allowInEditable: false,
@@ -150,135 +120,5 @@ export const Notifications = memo(function Notifications() {
])
)
- const preventZoomRef = usePreventZoom()
-
- const [isPaused, setIsPaused] = useState(false)
- const [exitingIds, setExitingIds] = useState>(new Set())
- const timersRef = useRef(new Map>())
-
- const pauseAll = useCallback(() => {
- setIsPaused(true)
- setExitingIds(new Set())
- for (const timer of timersRef.current.values()) clearTimeout(timer)
- timersRef.current.clear()
- }, [])
-
- /**
- * Manages per-notification dismiss timers.
- * Resets pause state when the notification stack empties so new arrivals get fresh timers.
- */
- useEffect(() => {
- if (visibleNotifications.length === 0) {
- if (isPaused) setIsPaused(false)
- return
- }
- if (isPaused) return
-
- const timers = timersRef.current
- const activeIds = new Set()
-
- for (const n of visibleNotifications) {
- if (!isAutoDismissable(n) || timers.has(n.id)) continue
- activeIds.add(n.id)
-
- timers.set(
- n.id,
- setTimeout(() => {
- timers.delete(n.id)
- setExitingIds((prev) => new Set(prev).add(n.id))
- setTimeout(() => {
- removeNotification(n.id)
- setExitingIds((prev) => {
- const next = new Set(prev)
- next.delete(n.id)
- return next
- })
- }, EXIT_ANIMATION_MS)
- }, AUTO_DISMISS_MS)
- )
- }
-
- for (const [id, timer] of timers) {
- if (!activeIds.has(id) && !visibleNotifications.some((n) => n.id === id)) {
- clearTimeout(timer)
- timers.delete(id)
- }
- }
- }, [visibleNotifications, removeNotification, isPaused])
-
- useEffect(() => {
- const timers = timersRef.current
- return () => {
- for (const timer of timers.values()) clearTimeout(timer)
- }
- }, [])
-
- if (visibleNotifications.length === 0) {
- return null
- }
-
- return (
-
- {[...visibleNotifications].reverse().map((notification, index, stacked) => {
- const depth = stacked.length - index - 1
- const xOffset = depth * STACK_OFFSET_PX
- const hasAction = Boolean(notification.action)
- const showCountdown = !isPaused && isAutoDismissable(notification)
-
- return (
-
-
-
-
- {notification.level === 'error' && (
-
- )}
- {notification.message}
-
-
- {showCountdown && }
-
-
- removeNotification(notification.id)}
- aria-label='Dismiss notification'
- className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
- >
-
-
-
-
- Clear all
-
-
-
-
- {hasAction && (
-
executeAction(notification.id, notification.action!)}
- className='w-full rounded-[5px] px-[8px] py-[4px] font-medium text-[12px]'
- >
- {ACTION_LABELS[notification.action!.type] ?? 'Take action'}
-
- )}
-
-
- )
- })}
-
- )
+ return null
})
diff --git a/apps/sim/components/emcn/components/toast/toast.tsx b/apps/sim/components/emcn/components/toast/toast.tsx
index c392ec38a47..371a327aa0a 100644
--- a/apps/sim/components/emcn/components/toast/toast.tsx
+++ b/apps/sim/components/emcn/components/toast/toast.tsx
@@ -1,21 +1,29 @@
'use client'
import {
+ type ReactElement,
type ReactNode,
createContext,
+ memo,
useCallback,
useContext,
useEffect,
+ useMemo,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
-import { cn } from '@/lib/core/utils/cn'
+import { Button } from '@/components/emcn/components/button/button'
+import { Tooltip } from '@/components/emcn/components/tooltip/tooltip'
-const AUTO_DISMISS_MS = 0
+const AUTO_DISMISS_MS = 10_000
const EXIT_ANIMATION_MS = 200
-const MAX_VISIBLE = 20
+const MAX_VISIBLE = 4
+const STACK_OFFSET_PX = 3
+
+const RING_RADIUS = 5.5
+const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
type ToastVariant = 'default' | 'success' | 'error'
@@ -27,16 +35,17 @@ interface ToastAction {
interface ToastData {
id: string
message: string
- description?: string
variant: ToastVariant
+ icon?: ReactElement
action?: ToastAction
duration: number
+ createdAt: number
}
type ToastInput = {
message: string
- description?: string
variant?: ToastVariant
+ icon?: ReactElement
action?: ToastAction
duration?: number
}
@@ -50,6 +59,7 @@ type ToastFn = {
interface ToastContextValue {
toast: ToastFn
dismiss: (id: string) => void
+ dismissAll: () => void
}
const ToastContext = createContext(null)
@@ -89,85 +99,131 @@ export function useToast() {
return ctx
}
-const VARIANT_STYLES: Record = {
- default: 'border-[var(--border)] bg-[var(--bg)] text-[var(--text-primary)]',
- success:
- 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/40 dark:bg-emerald-950/30 dark:text-emerald-200',
- error:
- 'border-red-200 bg-red-50 text-red-900 dark:border-red-800/40 dark:bg-red-950/30 dark:text-red-200',
+function CountdownRing({ durationMs, onPause }: { durationMs: number; onPause: () => void }) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ Keep visible
+
+
+ )
}
-function ToastItem({
- toast: t,
+const ToastItem = memo(function ToastItem({
+ data,
+ depth,
+ isExiting,
+ showCountdown,
onDismiss,
+ onPauseCountdown,
+ onAction,
}: {
- toast: ToastData
+ data: ToastData
+ depth: number
+ isExiting: boolean
+ showCountdown: boolean
onDismiss: (id: string) => void
+ onPauseCountdown: () => void
+ onAction: (id: string) => void
}) {
- const [exiting, setExiting] = useState(false)
- const timerRef = useRef>(undefined)
-
- const dismiss = useCallback(() => {
- setExiting(true)
- setTimeout(() => onDismiss(t.id), EXIT_ANIMATION_MS)
- }, [onDismiss, t.id])
-
- useEffect(() => {
- if (t.duration > 0) {
- timerRef.current = setTimeout(dismiss, t.duration)
- return () => clearTimeout(timerRef.current)
- }
- }, [dismiss, t.duration])
+ const xOffset = depth * STACK_OFFSET_PX
return (
-
-
{t.message}
- {t.description && (
-
{t.description}
+
+
+ {data.icon && (
+
{data.icon}
+ )}
+
+ {data.variant === 'error' && (
+
+ )}
+ {data.message}
+
+
+ {showCountdown && (
+
+ )}
+ onDismiss(data.id)}
+ aria-label='Dismiss'
+ className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
+ >
+
+
+
+
+ {data.action && (
+
onAction(data.id)}
+ className='w-full rounded-[5px] px-[8px] py-[4px] font-medium text-[12px]'
+ >
+ {data.action.label}
+
)}
- {t.action && (
-
{
- t.action!.onClick()
- dismiss()
- }}
- className='shrink-0 text-[13px] font-medium underline underline-offset-2 opacity-90 hover:opacity-100'
- >
- {t.action.label}
-
- )}
-
-
-
)
-}
+})
/**
* Toast container that renders toasts via portal.
- * Mount once in your root layout.
+ * Mount once where you want toasts to appear. Renders stacked cards in the bottom-right.
*
- * @example
- * ```tsx
- *
- * ```
+ * Visual design matches the workflow notification component: 240px cards, stacked with
+ * offset, countdown ring on auto-dismissing items, enter/exit animations.
*/
export function ToastProvider({ children }: { children?: ReactNode }) {
const [toasts, setToasts] = useState
([])
const [mounted, setMounted] = useState(false)
+ const [isPaused, setIsPaused] = useState(false)
+ const [exitingIds, setExitingIds] = useState>(new Set())
+ const timersRef = useRef(new Map>())
useEffect(() => {
setMounted(true)
@@ -178,17 +234,87 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
const data: ToastData = {
id,
message: input.message,
- description: input.description,
variant: input.variant ?? 'default',
+ icon: input.icon,
action: input.action,
duration: input.duration ?? AUTO_DISMISS_MS,
+ createdAt: Date.now(),
}
- setToasts((prev) => [...prev, data].slice(-MAX_VISIBLE))
+ setToasts((prev) => [data, ...prev].slice(0, MAX_VISIBLE))
return id
}, [])
const dismissToast = useCallback((id: string) => {
- setToasts((prev) => prev.filter((t) => t.id !== id))
+ setExitingIds((prev) => new Set(prev).add(id))
+ setTimeout(() => {
+ setToasts((prev) => prev.filter((t) => t.id !== id))
+ setExitingIds((prev) => {
+ const next = new Set(prev)
+ next.delete(id)
+ return next
+ })
+ }, EXIT_ANIMATION_MS)
+ }, [])
+
+ const dismissAll = useCallback(() => {
+ setToasts([])
+ setExitingIds(new Set())
+ for (const timer of timersRef.current.values()) clearTimeout(timer)
+ timersRef.current.clear()
+ }, [])
+
+ const pauseAll = useCallback(() => {
+ setIsPaused(true)
+ setExitingIds(new Set())
+ for (const timer of timersRef.current.values()) clearTimeout(timer)
+ timersRef.current.clear()
+ }, [])
+
+ const handleAction = useCallback(
+ (id: string) => {
+ const t = toasts.find((toast) => toast.id === id)
+ if (t?.action) {
+ t.action.onClick()
+ dismissToast(id)
+ }
+ },
+ [toasts, dismissToast]
+ )
+
+ useEffect(() => {
+ if (toasts.length === 0) {
+ if (isPaused) setIsPaused(false)
+ return
+ }
+ if (isPaused) return
+
+ const timers = timersRef.current
+
+ for (const t of toasts) {
+ if (t.duration <= 0 || timers.has(t.id)) continue
+
+ timers.set(
+ t.id,
+ setTimeout(() => {
+ timers.delete(t.id)
+ dismissToast(t.id)
+ }, t.duration)
+ )
+ }
+
+ for (const [id, timer] of timers) {
+ if (!toasts.some((t) => t.id === id)) {
+ clearTimeout(timer)
+ timers.delete(id)
+ }
+ }
+ }, [toasts, isPaused, dismissToast])
+
+ useEffect(() => {
+ const timers = timersRef.current
+ return () => {
+ for (const timer of timers.values()) clearTimeout(timer)
+ }
}, [])
const toastFn = useRef(createToastFn(addToast))
@@ -201,21 +327,37 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
}
}, [addToast])
- const ctx: ToastContextValue = { toast: toastFn.current, dismiss: dismissToast }
+ const ctx = useMemo(
+ () => ({ toast: toastFn.current, dismiss: dismissToast, dismissAll }),
+ [dismissToast, dismissAll]
+ )
+
+ const visibleToasts = toasts.slice(0, MAX_VISIBLE)
return (
{children}
{mounted &&
+ visibleToasts.length > 0 &&
createPortal(
-
- {toasts.map((t) => (
-
- ))}
+
+ {[...visibleToasts].reverse().map((t, index, stacked) => {
+ const depth = stacked.length - index - 1
+ const showCountdown = !isPaused && t.duration > 0
+
+ return (
+
+ )
+ })}
,
document.body
)}
From 1a46796d5a8f9486e590c1e65a740e6a02c3d2db Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Fri, 13 Mar 2026 17:58:20 -0700
Subject: [PATCH 3/6] Auth user if workspaceid not found
---
apps/sim/app/_styles/globals.css | 33 ----------
.../app/api/workflows/[id]/restore/route.ts | 2 +
.../emcn/components/toast/toast.tsx | 61 ++++++++++++-------
apps/sim/lib/knowledge/service.ts | 2 +-
4 files changed, 42 insertions(+), 56 deletions(-)
diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css
index a50c64c31a9..3de43832e54 100644
--- a/apps/sim/app/_styles/globals.css
+++ b/apps/sim/app/_styles/globals.css
@@ -666,39 +666,6 @@ input[type="search"]::-ms-clear {
}
}
-/**
- * Notification toast enter animation — pop-open with stack offset
- */
-@keyframes notification-enter {
- from {
- opacity: 0;
- transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97);
- }
- to {
- opacity: 1;
- transform: translateX(var(--stack-offset, 0px)) scale(1);
- }
-}
-
-@keyframes notification-countdown {
- from {
- stroke-dashoffset: 0;
- }
- to {
- stroke-dashoffset: 34.56;
- }
-}
-
-@keyframes notification-exit {
- from {
- opacity: 1;
- transform: translateX(var(--stack-offset, 0px)) scale(1);
- }
- to {
- opacity: 0;
- transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97);
- }
-}
/**
diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts
index d50f95dea27..7e8a76e8a35 100644
--- a/apps/sim/app/api/workflows/[id]/restore/route.ts
+++ b/apps/sim/app/api/workflows/[id]/restore/route.ts
@@ -32,6 +32,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
if (permission !== 'admin' && permission !== 'write') {
return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 })
}
+ } else if (workflowData.userId !== auth.userId) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const result = await restoreWorkflow(workflowId, { requestId })
diff --git a/apps/sim/components/emcn/components/toast/toast.tsx b/apps/sim/components/emcn/components/toast/toast.tsx
index 371a327aa0a..185e569fe9e 100644
--- a/apps/sim/components/emcn/components/toast/toast.tsx
+++ b/apps/sim/components/emcn/components/toast/toast.tsx
@@ -25,6 +25,20 @@ const STACK_OFFSET_PX = 3
const RING_RADIUS = 5.5
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
+const TOAST_KEYFRAMES = `
+@keyframes toast-enter {
+ from { opacity: 0; transform: translateX(calc(var(--stack-offset, 0px) - 8px)) scale(0.97); }
+ to { opacity: 1; transform: translateX(var(--stack-offset, 0px)) scale(1); }
+}
+@keyframes toast-exit {
+ from { opacity: 1; transform: translateX(var(--stack-offset, 0px)) scale(1); }
+ to { opacity: 0; transform: translateX(calc(var(--stack-offset, 0px) + 8px)) scale(0.97); }
+}
+@keyframes toast-countdown {
+ from { stroke-dashoffset: 0; }
+ to { stroke-dashoffset: ${RING_CIRCUMFERENCE.toFixed(2)}; }
+}`
+
type ToastVariant = 'default' | 'success' | 'error'
interface ToastAction {
@@ -127,7 +141,7 @@ function CountdownRing({ durationMs, onPause }: { durationMs: number; onPause: (
strokeLinecap='round'
strokeDasharray={RING_CIRCUMFERENCE}
style={{
- animation: `notification-countdown ${durationMs}ms linear forwards`,
+ animation: `toast-countdown ${durationMs}ms linear forwards`,
}}
/>
@@ -165,8 +179,8 @@ const ToastItem = memo(function ToastItem({
{
'--stack-offset': `${xOffset}px`,
animation: isExiting
- ? `notification-exit ${EXIT_ANIMATION_MS}ms ease-in forwards`
- : 'notification-enter 200ms ease-out forwards',
+ ? `toast-exit ${EXIT_ANIMATION_MS}ms ease-in forwards`
+ : 'toast-enter 200ms ease-out forwards',
gridArea: '1 / 1',
} as React.CSSProperties
}
@@ -340,25 +354,28 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
{mounted &&
visibleToasts.length > 0 &&
createPortal(
-
- {[...visibleToasts].reverse().map((t, index, stacked) => {
- const depth = stacked.length - index - 1
- const showCountdown = !isPaused && t.duration > 0
-
- return (
-
- )
- })}
-
,
+ <>
+
+
+ {[...visibleToasts].reverse().map((t, index, stacked) => {
+ const depth = stacked.length - index - 1
+ const showCountdown = !isPaused && t.duration > 0
+
+ return (
+
+ )
+ })}
+
+ >,
document.body
)}
diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts
index cb4169e5705..ea24e7ceeac 100644
--- a/apps/sim/lib/knowledge/service.ts
+++ b/apps/sim/lib/knowledge/service.ts
@@ -425,7 +425,7 @@ export async function restoreKnowledgeBase(
await tx
.update(knowledgeConnector)
- .set({ archivedAt: null, updatedAt: now })
+ .set({ archivedAt: null, status: 'active', updatedAt: now })
.where(
and(
eq(knowledgeConnector.knowledgeBaseId, knowledgeBaseId),
From 9e86e3d7eb9532183dc2e911aa82018dd744979d Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Fri, 13 Mar 2026 18:25:50 -0700
Subject: [PATCH 4/6] Fix recently deleted ui
---
.../components/recently-deleted/recently-deleted.tsx | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
index 44c60dc85a1..b44376d6de4 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
@@ -10,7 +10,6 @@ import {
SModalTabsList,
SModalTabsTrigger,
toast,
- Undo,
} from '@/components/emcn'
import { Input } from '@/components/ui'
import { formatDate } from '@/lib/core/utils/formatting'
@@ -242,7 +241,7 @@ export function RecentlyDeleted() {
value={activeTab}
onValueChange={(v) => setActiveTab(v as ResourceType)}
>
-
+
{TABS.map((tab) => (
{tab.label}
@@ -287,7 +286,7 @@ export function RecentlyDeleted() {
handleRestore(resource)}
@@ -296,9 +295,8 @@ export function RecentlyDeleted() {
{isRestoring ? (
) : (
-
+ 'Restore'
)}
- Restore
)
From 1967109ab68dfd6f76a0fa7be5cd25a29b6e44cd Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Fri, 13 Mar 2026 18:34:50 -0700
Subject: [PATCH 5/6] Add restore error toast
---
.../components/recently-deleted/recently-deleted.tsx | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
index b44376d6de4..5eec49028c9 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
@@ -198,20 +198,24 @@ export function RecentlyDeleted() {
})
}
+ const onError = () => {
+ toast.error(`Failed to restore ${resource.name}`)
+ }
+
switch (resource.type) {
case 'workflow':
- restoreWorkflow.mutate(resource.id, { onSettled, onSuccess })
+ restoreWorkflow.mutate(resource.id, { onSettled, onSuccess, onError })
break
case 'table':
- restoreTable.mutate(resource.id, { onSettled, onSuccess })
+ restoreTable.mutate(resource.id, { onSettled, onSuccess, onError })
break
case 'knowledge':
- restoreKnowledgeBase.mutate(resource.id, { onSettled, onSuccess })
+ restoreKnowledgeBase.mutate(resource.id, { onSettled, onSuccess, onError })
break
case 'file':
restoreWorkspaceFile.mutate(
{ workspaceId: resource.workspaceId, fileId: resource.id },
- { onSettled, onSuccess }
+ { onSettled, onSuccess, onError }
)
break
}
From aab13816fe73b271b7bc9711cdacc7fdad26e246 Mon Sep 17 00:00:00 2001
From: Theodore Li
Date: Fri, 13 Mar 2026 18:48:57 -0700
Subject: [PATCH 6/6] Fix deleted at timestamp mismatch
---
.../settings/components/recently-deleted/recently-deleted.tsx | 4 ++--
apps/sim/components/emcn/components/toast/toast.tsx | 2 +-
apps/sim/hooks/queries/workflows.ts | 1 +
apps/sim/lib/knowledge/service.ts | 4 ++++
apps/sim/lib/knowledge/types.ts | 2 ++
apps/sim/stores/workflows/registry/types.ts | 1 +
6 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
index 5eec49028c9..2ebc482508c 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx
@@ -128,7 +128,7 @@ export function RecentlyDeleted() {
id: wf.id,
name: wf.name,
type: 'workflow',
- deletedAt: new Date(wf.lastModified),
+ deletedAt: wf.archivedAt ? new Date(wf.archivedAt) : new Date(wf.lastModified),
workspaceId: wf.workspaceId ?? workspaceId,
color: wf.color,
})
@@ -149,7 +149,7 @@ export function RecentlyDeleted() {
id: kb.id,
name: kb.name,
type: 'knowledge',
- deletedAt: new Date(kb.updatedAt),
+ deletedAt: kb.deletedAt ? new Date(kb.deletedAt) : new Date(kb.updatedAt),
workspaceId: kb.workspaceId ?? workspaceId,
})
}
diff --git a/apps/sim/components/emcn/components/toast/toast.tsx b/apps/sim/components/emcn/components/toast/toast.tsx
index 185e569fe9e..c9f75f3c46a 100644
--- a/apps/sim/components/emcn/components/toast/toast.tsx
+++ b/apps/sim/components/emcn/components/toast/toast.tsx
@@ -189,7 +189,7 @@ const ToastItem = memo(function ToastItem({
{data.icon && (
-
{data.icon}
+
{data.icon}
)}
{data.variant === 'error' && (
diff --git a/apps/sim/hooks/queries/workflows.ts b/apps/sim/hooks/queries/workflows.ts
index d3181becfb2..88d19f2cb3f 100644
--- a/apps/sim/hooks/queries/workflows.ts
+++ b/apps/sim/hooks/queries/workflows.ts
@@ -76,6 +76,7 @@ function mapWorkflow(workflow: any): WorkflowMetadata {
sortOrder: workflow.sortOrder ?? 0,
createdAt: new Date(workflow.createdAt),
lastModified: new Date(workflow.updatedAt || workflow.createdAt),
+ archivedAt: workflow.archivedAt ? new Date(workflow.archivedAt) : null,
}
}
diff --git a/apps/sim/lib/knowledge/service.ts b/apps/sim/lib/knowledge/service.ts
index ea24e7ceeac..271d4934d0d 100644
--- a/apps/sim/lib/knowledge/service.ts
+++ b/apps/sim/lib/knowledge/service.ts
@@ -41,6 +41,7 @@ export async function getKnowledgeBases(
chunkingConfig: knowledgeBase.chunkingConfig,
createdAt: knowledgeBase.createdAt,
updatedAt: knowledgeBase.updatedAt,
+ deletedAt: knowledgeBase.deletedAt,
workspaceId: knowledgeBase.workspaceId,
docCount: count(document.id),
})
@@ -171,6 +172,7 @@ export async function createKnowledgeBase(
chunkingConfig: data.chunkingConfig,
createdAt: now,
updatedAt: now,
+ deletedAt: null,
workspaceId: data.workspaceId,
docCount: 0,
connectorTypes: [],
@@ -237,6 +239,7 @@ export async function updateKnowledgeBase(
chunkingConfig: knowledgeBase.chunkingConfig,
createdAt: knowledgeBase.createdAt,
updatedAt: knowledgeBase.updatedAt,
+ deletedAt: knowledgeBase.deletedAt,
workspaceId: knowledgeBase.workspaceId,
docCount: count(document.id),
})
@@ -286,6 +289,7 @@ export async function getKnowledgeBaseById(
chunkingConfig: knowledgeBase.chunkingConfig,
createdAt: knowledgeBase.createdAt,
updatedAt: knowledgeBase.updatedAt,
+ deletedAt: knowledgeBase.deletedAt,
workspaceId: knowledgeBase.workspaceId,
docCount: count(document.id),
})
diff --git a/apps/sim/lib/knowledge/types.ts b/apps/sim/lib/knowledge/types.ts
index f86fb1dc707..b761597c790 100644
--- a/apps/sim/lib/knowledge/types.ts
+++ b/apps/sim/lib/knowledge/types.ts
@@ -26,6 +26,7 @@ export interface KnowledgeBaseWithCounts {
chunkingConfig: ChunkingConfig
createdAt: Date
updatedAt: Date
+ deletedAt: Date | null
workspaceId: string | null
docCount: number
connectorTypes: string[]
@@ -126,6 +127,7 @@ export interface KnowledgeBaseData {
chunkingConfig: ExtendedChunkingConfig
createdAt: string
updatedAt: string
+ deletedAt?: string | null
workspaceId?: string
connectorTypes?: string[]
}
diff --git a/apps/sim/stores/workflows/registry/types.ts b/apps/sim/stores/workflows/registry/types.ts
index 3c49bf23362..a6bec226690 100644
--- a/apps/sim/stores/workflows/registry/types.ts
+++ b/apps/sim/stores/workflows/registry/types.ts
@@ -27,6 +27,7 @@ export interface WorkflowMetadata {
workspaceId?: string
folderId?: string | null
sortOrder: number
+ archivedAt?: Date | null
}
export type HydrationPhase =